Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
927e27640a | ||
|
|
5255cd2d30 | ||
|
|
3723ba7ed1 | ||
|
|
c5148ac4a0 | ||
|
|
e6e319a275 | ||
|
|
869b13cb57 | ||
|
|
e190785d4f | ||
|
|
3fd05ff833 | ||
|
|
e7f24f0be4 | ||
|
|
1bfe460e4b | ||
|
|
e55d2c5aba | ||
|
|
f63e9b5fd0 | ||
|
|
0148d4ca37 | ||
|
|
e0ecabb22f | ||
|
|
da301b1a94 |
@@ -5,7 +5,7 @@ user-invocable: false
|
||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||
---
|
||||
|
||||
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
|
||||
# ReUI for Agents
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ReUI components
|
||||
|
||||
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
|
||||
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
||||
|
||||
@@ -106,22 +106,60 @@ Common mistakes:
|
||||
|
||||
## filters
|
||||
|
||||
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
||||
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
const [filters, setFilters] = useState<Filter[]>([
|
||||
createFilter("priority", "is_any_of", ["low"]),
|
||||
])
|
||||
const fields: FilterFieldConfig[] = [
|
||||
{ key: "priority", label: "Priority", type: "multiselect",
|
||||
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
|
||||
const fields: FilterField[] = [
|
||||
{ id: "title", label: "Title", type: "text" },
|
||||
{
|
||||
id: "status",
|
||||
label: "Status",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "archived", label: "Archived" },
|
||||
],
|
||||
},
|
||||
]
|
||||
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||
|
||||
<Filters filters={filters} fields={fields} onChange={setFilters} />
|
||||
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||
```
|
||||
|
||||
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
|
||||
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||
|
||||
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||
|
||||
## cascader
|
||||
|
||||
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||
<CascaderTrigger render={<Button variant="outline" />}>
|
||||
<CascaderValue placeholder="Select an attribute" />
|
||||
</CascaderTrigger>
|
||||
<CascaderContent className="w-80">
|
||||
<CascaderPanel>
|
||||
<CascaderNav>
|
||||
<CascaderBreadcrumb />
|
||||
<CascaderInput />
|
||||
</CascaderNav>
|
||||
<CascaderEmpty />
|
||||
<CascaderList maxHeight={288}>
|
||||
<CascaderItems />
|
||||
</CascaderList>
|
||||
<CascaderStatus />
|
||||
</CascaderPanel>
|
||||
</CascaderContent>
|
||||
</Cascader>
|
||||
```
|
||||
|
||||
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||
|
||||
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||
|
||||
## date-selector
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ user-invocable: false
|
||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||
---
|
||||
|
||||
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
|
||||
# ReUI for Agents
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ReUI components
|
||||
|
||||
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
|
||||
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
||||
|
||||
@@ -106,22 +106,60 @@ Common mistakes:
|
||||
|
||||
## filters
|
||||
|
||||
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
||||
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
const [filters, setFilters] = useState<Filter[]>([
|
||||
createFilter("priority", "is_any_of", ["low"]),
|
||||
])
|
||||
const fields: FilterFieldConfig[] = [
|
||||
{ key: "priority", label: "Priority", type: "multiselect",
|
||||
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
|
||||
const fields: FilterField[] = [
|
||||
{ id: "title", label: "Title", type: "text" },
|
||||
{
|
||||
id: "status",
|
||||
label: "Status",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "archived", label: "Archived" },
|
||||
],
|
||||
},
|
||||
]
|
||||
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||
|
||||
<Filters filters={filters} fields={fields} onChange={setFilters} />
|
||||
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||
```
|
||||
|
||||
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
|
||||
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||
|
||||
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||
|
||||
## cascader
|
||||
|
||||
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||
<CascaderTrigger render={<Button variant="outline" />}>
|
||||
<CascaderValue placeholder="Select an attribute" />
|
||||
</CascaderTrigger>
|
||||
<CascaderContent className="w-80">
|
||||
<CascaderPanel>
|
||||
<CascaderNav>
|
||||
<CascaderBreadcrumb />
|
||||
<CascaderInput />
|
||||
</CascaderNav>
|
||||
<CascaderEmpty />
|
||||
<CascaderList maxHeight={288}>
|
||||
<CascaderItems />
|
||||
</CascaderList>
|
||||
<CascaderStatus />
|
||||
</CascaderPanel>
|
||||
</CascaderContent>
|
||||
</Cascader>
|
||||
```
|
||||
|
||||
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||
|
||||
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||
|
||||
## date-selector
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ alwaysApply: true
|
||||
| Библиотека | Context7 ID | Версия в проекте | Когда |
|
||||
|------------|-------------|------------------|-------|
|
||||
| OpenAPI | `/oai/openapi-specification` | 3.x в `docs/openapi.yaml` | схемы, operationId, problem+json |
|
||||
| Redocly CLI | `/redocly/redocly-cli` | CI `@redocly/cli` | lint OpenAPI, `npx @redocly/cli lint` |
|
||||
| Redocly CLI | `/redocly/redocly-cli` | CI `@redocly/cli` | lint OpenAPI, `pnpm exec redocly lint` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ alwaysApply: true
|
||||
## Dependency Management
|
||||
|
||||
**DEP-01** | MUST | Go-зависимости — через `go get` / `go.mod`; версия Go как в `go.mod` и CI (1.24).
|
||||
*Проверка:* `go.mod`, `.gitea/workflows/ci.yaml`.
|
||||
*Проверка:* `go.mod`, `.gitea/workflows/quality.yaml`.
|
||||
|
||||
**DEP-02** | NEVER | Vendor-копирование без явного решения в репозитории.
|
||||
*Проверка:* review.
|
||||
@@ -123,10 +123,10 @@ alwaysApply: true
|
||||
**TEST-03** | MUST | Новые BIRD-сценарии в `internal/birdfmt/testdata/scenarios/*/bird.conf` + `bird -p`.
|
||||
*Проверка:* CI job `bird2`.
|
||||
|
||||
**TEST-04** | MUST | Изменения `apps/web/**` или `packages/ui/**` — локально **`pnpm --filter @evobgp/web run typecheck`, `lint`, `build`** (все три команды, exit 0); CI job `web` в `.gitea/workflows/ci.yaml`.
|
||||
**TEST-04** | MUST | Изменения `apps/web/**` или `packages/ui/**` — локально **`pnpm --filter @evobgp/web run typecheck`, `lint`, `build`** (все три команды, exit 0); CI job `web` в `.gitea/workflows/quality.yaml`.
|
||||
*Проверка:* CI job `web`; `.cursor/rules/web-shadcn.mdc` WEB-19.
|
||||
|
||||
**TEST-05** | MUST | Изменения OpenAPI — `npx @redocly/cli lint docs/openapi.yaml`.
|
||||
**TEST-05** | MUST | Изменения OpenAPI — `pnpm exec redocly lint docs/openapi.yaml`.
|
||||
*Проверка:* CI job `openapi`.
|
||||
|
||||
---
|
||||
@@ -229,7 +229,7 @@ alwaysApply: true
|
||||
```powershell
|
||||
go vet ./...
|
||||
go test ./... -race -count=1
|
||||
npx @redocly/cli lint docs/openapi.yaml
|
||||
pnpm exec redocly lint docs/openapi.yaml
|
||||
# web: pnpm --filter @evobgp/web run typecheck; pnpm --filter @evobgp/web run lint; pnpm --filter @evobgp/web run build
|
||||
# go fmt/lint: gofmt -w <files>; scripts/lint-go.ps1 (gofmt + vet + golangci-lint)
|
||||
# birdfmt: go test ./internal/birdfmt/... -count=1
|
||||
|
||||
@@ -6,18 +6,18 @@ alwaysApply: false
|
||||
|
||||
---
|
||||
name: reui
|
||||
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 17 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
||||
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
||||
user-invocable: false
|
||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||
---
|
||||
|
||||
> **ReUI skill version `42d70dcc3d`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
|
||||
# ReUI for Agents
|
||||
|
||||
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
|
||||
|
||||
- **components** - the 17 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
||||
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
||||
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
|
||||
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
|
||||
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
||||
@@ -64,7 +64,7 @@ Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor
|
||||
|
||||
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
|
||||
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
|
||||
- [rules/components.md](./rules/components.md) - the 17 components, the data-grid contract, base vs radix
|
||||
- [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix
|
||||
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
|
||||
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
||||
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
||||
|
||||
@@ -5,7 +5,7 @@ user-invocable: false
|
||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||
---
|
||||
|
||||
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
|
||||
# ReUI for Agents
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ReUI components
|
||||
|
||||
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
|
||||
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
||||
|
||||
@@ -106,22 +106,60 @@ Common mistakes:
|
||||
|
||||
## filters
|
||||
|
||||
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
||||
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
const [filters, setFilters] = useState<Filter[]>([
|
||||
createFilter("priority", "is_any_of", ["low"]),
|
||||
])
|
||||
const fields: FilterFieldConfig[] = [
|
||||
{ key: "priority", label: "Priority", type: "multiselect",
|
||||
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
|
||||
const fields: FilterField[] = [
|
||||
{ id: "title", label: "Title", type: "text" },
|
||||
{
|
||||
id: "status",
|
||||
label: "Status",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "archived", label: "Archived" },
|
||||
],
|
||||
},
|
||||
]
|
||||
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||
|
||||
<Filters filters={filters} fields={fields} onChange={setFilters} />
|
||||
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||
```
|
||||
|
||||
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
|
||||
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||
|
||||
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||
|
||||
## cascader
|
||||
|
||||
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||
<CascaderTrigger render={<Button variant="outline" />}>
|
||||
<CascaderValue placeholder="Select an attribute" />
|
||||
</CascaderTrigger>
|
||||
<CascaderContent className="w-80">
|
||||
<CascaderPanel>
|
||||
<CascaderNav>
|
||||
<CascaderBreadcrumb />
|
||||
<CascaderInput />
|
||||
</CascaderNav>
|
||||
<CascaderEmpty />
|
||||
<CascaderList maxHeight={288}>
|
||||
<CascaderItems />
|
||||
</CascaderList>
|
||||
<CascaderStatus />
|
||||
</CascaderPanel>
|
||||
</CascaderContent>
|
||||
</Cascader>
|
||||
```
|
||||
|
||||
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||
|
||||
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||
|
||||
## date-selector
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# Build context = repository root (deploy/docker/docker-bake.hcl).
|
||||
.git
|
||||
.gitea
|
||||
.github
|
||||
.cursor
|
||||
.claude
|
||||
.codegraph
|
||||
.agents
|
||||
memory-bank
|
||||
.vscode
|
||||
.idea
|
||||
|
||||
**/.DS_Store
|
||||
**/Thumbs.db
|
||||
**/.env
|
||||
**/.env.*
|
||||
!**/.env.example
|
||||
!**/.env.*.example
|
||||
|
||||
node_modules
|
||||
**/node_modules
|
||||
**/dist
|
||||
apps/web/src/routeTree.gen.ts
|
||||
apps/web/playwright-report
|
||||
apps/web/test-results
|
||||
|
||||
*.md
|
||||
AGENTS.md
|
||||
CONTRIBUTING.md
|
||||
LICENSE
|
||||
docs
|
||||
|
||||
.pre-commit-config.yaml
|
||||
.golangci.yml
|
||||
.releaserc.json
|
||||
.commitlintrc.*
|
||||
redocly.yaml
|
||||
package-lock.json
|
||||
|
||||
data
|
||||
*.exe
|
||||
*.test
|
||||
coverage
|
||||
.coverage
|
||||
*.out
|
||||
.release-version
|
||||
CHANGELOG.md
|
||||
deploy/docker/docker-bake.override.hcl
|
||||
deploy/compose/runtime-logs
|
||||
+41
-16
@@ -1,38 +1,43 @@
|
||||
# Gitea Actions
|
||||
|
||||
Workflow: [workflows/ci.yaml](workflows/ci.yaml).
|
||||
|
||||
| Job | PR | push в main |
|
||||
|-----|-----|-------------|
|
||||
| changes, openapi, web, go, bird2 | quality gates | quality gates |
|
||||
| commitlint | да | — |
|
||||
| **release** | — | semantic-release + docker push (один run) |
|
||||
| Workflow | Когда | Что |
|
||||
|----------|--------|-----|
|
||||
| [workflows/ci.yaml](workflows/ci.yaml) | pull request в main/master | quality gates + commitlint |
|
||||
| [workflows/cd.yaml](workflows/cd.yaml) | push в main/master | quality gates + semantic-release + docker push |
|
||||
| [workflows/quality.yaml](workflows/quality.yaml) | reusable (`workflow_call`) | changes, openapi, web, go, bird2, commitlint, docker-check |
|
||||
|
||||
Подробнее: [docs/releasing.md](../docs/releasing.md).
|
||||
|
||||
## CI (quality gates)
|
||||
|
||||
Job **changes** вычисляет флаги по путям в diff. Полный прогон (все узлы openapi / web / go / bird2 в графе): `.gitea/workflows/*`, `scripts/*`, `.golangci.yml`, `.pre-commit-config.yaml`, корневой `package.json` / `.releaserc.json`. Отдельно: `migrations/*`, `docs/openapi.yaml` → `go` / `openapi` и т.д. (см. `ci.yaml`).
|
||||
Job **changes** вычисляет флаги по путям в diff. Полный прогон: `.gitea/workflows/*`, `scripts/*`, `.golangci.yml`, `.pre-commit-config.yaml`, корневой `package.json` / `.releaserc.json`. Правки `.cursor/`, `.claude/`, `*.md` (кроме `docs/api.md` / `docs/access.md` / `docs/openapi.yaml`) quality jobs не запускают.
|
||||
|
||||
На **pull request** — **commitlint** (Conventional Commits).
|
||||
На **pull request** — **commitlint**. При изменении `deploy/docker/**` — job **docker-check** (`bake --print`, bake без `--push` если есть доступ к registry).
|
||||
|
||||
Runner: `ubuntu-latest`, **bird2** из apt, Docker для job **release**.
|
||||
Кэш зависимостей — нативный `actions/cache` (cache server act_runner), ключ `sha256sum` lockfile (не `hashFiles`). Пути **абсолютные** (`$HOME/.pnpm-store`, `go env GOMODCACHE` / `GOCACHE`): тильда `~` на Gitea часто не раскрывается и даёт вечный miss.
|
||||
|
||||
## Release (job в ci.yaml)
|
||||
Кэшируется целиком: pnpm store + `node_modules` + corepack; Go modules + GOCACHE + `golangci-lint` в `GOBIN`. При hit: `pnpm install --offline`, `go mod download` без сети. `setup-go cache:` и `golangci-lint-action` не используем — они завязаны на `hashFiles`.
|
||||
|
||||
После успешных quality gates на **push в main** job **release**:
|
||||
Если restore пишет `connect ECONNREFUSED` / `cache server not configured` — на runner включите cache server (см. ниже). Иначе каждый job снова качает пакеты (~минуты).
|
||||
|
||||
1. `npx semantic-release` — тег `vX.Y.Z` на **текущий commit** (без дополнительного commit в main).
|
||||
Runner: `ubuntu-latest`, **bird2** из apt, Docker для **docker-check** (PR) и **publish** (CD).
|
||||
|
||||
## CD (job publish)
|
||||
|
||||
После успешных quality gates на **push в main** job **publish**:
|
||||
|
||||
1. `pnpm exec semantic-release` — тег `vX.Y.Z` на **текущий commit** (без дополнительного commit в main).
|
||||
2. Gitea Release + `CHANGELOG.md` как attachment (не в git).
|
||||
3. `docker buildx bake default --push` с `VERSION=X.Y.Z` — в том же job.
|
||||
3. Зеркало base-образов в `evobgp-buildcache:base-*` (`deploy/docker/mirror-base-images.sh`; skip существующих тегов, `linux/amd64`, retry при 429).
|
||||
4. `docker buildx bake default --push` с `VERSION=X.Y.Z`, `pull=false`, named builder `evobgp` (`cleanup: false`).
|
||||
|
||||
Если releasable-коммитов нет — semantic-release no-op, образы не публикуются.
|
||||
|
||||
Повтор упавшего **release** (тег уже есть, bake нет): detect берёт `v*` на `HEAD` и всё равно пушит образы. Подробнее: [docs/releasing.md](../docs/releasing.md#перезапуск-упавшего-job-release).
|
||||
Повтор упавшего **publish** (тег уже есть, bake нет): detect берёт `v*` на `HEAD` и всё равно пушит образы. Подробнее: [docs/releasing.md](../docs/releasing.md#перезапуск-упавшего-job-publish).
|
||||
|
||||
### Секреты
|
||||
|
||||
**`ACTIONS_PAT`**: push tags, releases, Container Registry. Fallback: **`gitea.token`**.
|
||||
**`ACTIONS_PAT`**: push tags, releases, Container Registry. Для git tag fallback: `github.token`. Push OCI — **только PAT** (у `GITEA_TOKEN` нет права packages).
|
||||
|
||||
### Теги образов
|
||||
|
||||
@@ -46,6 +51,8 @@ git.shx.one/<owner>/<имя>:sha-<full-sha>
|
||||
|
||||
Имена образов: `evobgp-api`, `evobgp-all`, `evobgp-scheduler`, `evobgp-ingest`, `evobgp-render`, `evobgp-deploy`, `evobgp-node`, `evobgp-web`, `evobgp-web-all`, `evobgp-agent`, `evobgp-bird2`.
|
||||
|
||||
Кэш сборки: `evobgp-buildcache:{go,web,birdc}-buildcache` и `evobgp-buildcache:base-*`.
|
||||
|
||||
**Удалённый спикер** (compose `deploy/compose/docker-compose.remote-speaker.yaml`): `evobgp-bird2`, `evobgp-agent`, `evobgp-node` (fallback profile); Traefik — внешний `traefik:latest`. CI: `scripts/validate-remote-speaker-compose.sh`.
|
||||
|
||||
Пример:
|
||||
@@ -55,3 +62,21 @@ docker pull git.shx.one/myuser/evobgp-api:1.2.3
|
||||
```
|
||||
|
||||
См. [deploy/docker/README.md](../deploy/docker/README.md), [docs/quickstart.md](../docs/quickstart.md).
|
||||
|
||||
## act_runner: cache server
|
||||
|
||||
`actions/cache` ходит в **встроенный cache server** runner (не GitHub `type=gha`). Кэш локален для этого runner.
|
||||
|
||||
В `config.yaml` runner:
|
||||
|
||||
```yaml
|
||||
cache:
|
||||
enabled: true
|
||||
dir: "" # по умолчанию $HOME/.cache/actcache
|
||||
host: "" # IP, доступный из job-контейнера (не 0.0.0.0)
|
||||
port: 8088
|
||||
```
|
||||
|
||||
Если runner в Docker, а jobs — отдельные контейнеры: пробросьте порт и задайте `host` (LAN IP хоста) или `external_server: "http://<host>:8088/"`. Иначе restore — timeout/ECONNREFUSED и пакеты качаются снова.
|
||||
|
||||
Не делайте `docker system prune -a` по cron: сотрётся и Docker-кэш FROM, и пользы от `cleanup: false` у buildx не будет.
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
name: CD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
uses: ./.gitea/workflows/quality.yaml
|
||||
with:
|
||||
is_pull_request: false
|
||||
before_sha: ${{ github.event.before }}
|
||||
head_sha: ${{ github.sha }}
|
||||
allow_registry_login: false
|
||||
secrets:
|
||||
ACTIONS_PAT: ${{ secrets.ACTIONS_PAT }}
|
||||
|
||||
publish:
|
||||
needs: [quality]
|
||||
if: >-
|
||||
always() &&
|
||||
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') &&
|
||||
needs.quality.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
releases: write
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
token: ${{ secrets.ACTIONS_PAT || gitea.token }}
|
||||
persist-credentials: true
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: Export cache paths
|
||||
run: sh scripts/ci/export-cache-env.sh
|
||||
- id: pnpm-hash
|
||||
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
|
||||
- id: pnpm-cache
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||
with:
|
||||
path: |
|
||||
${{ env.PNPM_STORE_DIR }}
|
||||
${{ env.COREPACK_HOME }}
|
||||
node_modules
|
||||
apps/web/node_modules
|
||||
packages/ui/node_modules
|
||||
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
|
||||
restore-keys: |
|
||||
pnpm-${{ runner.os }}-
|
||||
- name: Install release tooling
|
||||
env:
|
||||
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
|
||||
run: sh scripts/ci/pnpm-ci.sh
|
||||
- name: Verify releasable commit messages
|
||||
run: pnpm exec node scripts/commit/verify-release-commits.mjs
|
||||
- name: Semantic release
|
||||
run: pnpm exec semantic-release
|
||||
env:
|
||||
GITEA_URL: https://git.shx.one
|
||||
GITEA_TOKEN: ${{ secrets.ACTIONS_PAT || gitea.token }}
|
||||
- name: Detect new release
|
||||
id: rel
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version=""
|
||||
if [ -f .release-version ]; then
|
||||
version="$(tr -d '[:space:]' < .release-version)"
|
||||
echo "New release from semantic-release: $version"
|
||||
else
|
||||
git fetch --tags --force origin || true
|
||||
tag="$(git tag --points-at HEAD --list 'v*.*.*' | sort -V | tail -n1 || true)"
|
||||
if [ -n "${tag:-}" ]; then
|
||||
version="${tag#v}"
|
||||
echo "Reuse existing tag $tag on HEAD (release retry)"
|
||||
fi
|
||||
fi
|
||||
if [ -n "${version:-}" ]; then
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
echo "released=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "released=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No releasable commits — skipping image publish"
|
||||
fi
|
||||
- name: Set up Docker Buildx
|
||||
if: steps.rel.outputs.released == 'true'
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
with:
|
||||
name: evobgp
|
||||
driver: docker-container
|
||||
cleanup: false
|
||||
- name: Prepare image metadata
|
||||
if: steps.rel.outputs.released == 'true'
|
||||
id: meta
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "version=${{ steps.rel.outputs.version }}" >> "$GITHUB_OUTPUT"
|
||||
owner_lc="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
|
||||
echo "owner_lc=$owner_lc" >> "$GITHUB_OUTPUT"
|
||||
short_sha="$(echo '${{ github.sha }}' | cut -c1-7)"
|
||||
echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT"
|
||||
echo "build_time=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
||||
- name: Log in to Gitea Registry
|
||||
if: steps.rel.outputs.released == 'true'
|
||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||
with:
|
||||
registry: git.shx.one
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.ACTIONS_PAT }}
|
||||
- name: Mirror base images into buildcache
|
||||
if: steps.rel.outputs.released == 'true'
|
||||
env:
|
||||
REGISTRY: git.shx.one/${{ steps.meta.outputs.owner_lc }}
|
||||
MIRROR_ENV_FILE: ${{ runner.temp }}/mirror-base.env
|
||||
run: sh deploy/docker/mirror-base-images.sh
|
||||
- name: Build and push images (bake)
|
||||
if: steps.rel.outputs.released == 'true'
|
||||
env:
|
||||
REGISTRY: git.shx.one/${{ steps.meta.outputs.owner_lc }}
|
||||
IMAGE_TAG: latest
|
||||
VERSION: ${{ steps.meta.outputs.version }}
|
||||
SHORT_SHA: ${{ steps.meta.outputs.short_sha }}
|
||||
SHA_FULL: ${{ github.sha }}
|
||||
BUILD_TIME: ${{ steps.meta.outputs.build_time }}
|
||||
CACHE_REF_GO: git.shx.one/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:go-buildcache
|
||||
CACHE_REF_WEB: git.shx.one/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:web-buildcache
|
||||
CACHE_REF_BIRDC: git.shx.one/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:birdc-buildcache
|
||||
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
|
||||
BUILDX_BAKE_FILE_RELATIVE_PATHS: "1"
|
||||
MIRROR_ENV_FILE: ${{ runner.temp }}/mirror-base.env
|
||||
working-directory: deploy/docker
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
if [ -f "${MIRROR_ENV_FILE}" ]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
. "${MIRROR_ENV_FILE}"
|
||||
set +a
|
||||
fi
|
||||
docker buildx bake --allow=fs.read="${{ github.workspace }}" \
|
||||
-f docker-bake.hcl default --push
|
||||
+16
-380
@@ -1,387 +1,23 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# ---------------------------------------------------------------------------
|
||||
# Детекция изменений по модулям (флаги → downstream-джобы в графе CI).
|
||||
# Полный прогон (все флаги true): .gitea/workflows/*, scripts/*, .golangci.yml,
|
||||
# .pre-commit-config.yaml — чтобы при правках CI/CD пересобирались все узлы.
|
||||
# ---------------------------------------------------------------------------
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
openapi: ${{ steps.detect.outputs.openapi }}
|
||||
go: ${{ steps.detect.outputs.go }}
|
||||
web: ${{ steps.detect.outputs.web }}
|
||||
bird_conf: ${{ steps.detect.outputs.bird_conf }}
|
||||
docker_go: ${{ steps.detect.outputs.docker_go }}
|
||||
docker_web: ${{ steps.detect.outputs.docker_web }}
|
||||
docker_bird: ${{ steps.detect.outputs.docker_bird }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- id: detect
|
||||
name: Detect changed paths per module
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
openapi=false
|
||||
go=false
|
||||
web=false
|
||||
bird_conf=false
|
||||
docker_go=false
|
||||
docker_web=false
|
||||
docker_bird=false
|
||||
|
||||
# Все флаги true → openapi, web, go, bird2 (и release на main) в графе CI.
|
||||
set_all_flags_true() {
|
||||
openapi=true
|
||||
go=true
|
||||
web=true
|
||||
bird_conf=true
|
||||
docker_go=true
|
||||
docker_web=true
|
||||
docker_bird=true
|
||||
}
|
||||
|
||||
write_outputs() {
|
||||
for v in openapi go web bird_conf docker_go docker_web docker_bird; do
|
||||
eval "echo \"\$v=\$$v\"" >> "$GITHUB_OUTPUT"
|
||||
done
|
||||
}
|
||||
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
base="${{ github.event.pull_request.base.sha }}"
|
||||
head="${{ github.event.pull_request.head.sha }}"
|
||||
FILES="$(git diff --name-only "$base" "$head")"
|
||||
else
|
||||
before="${{ github.event.before }}"
|
||||
after="${{ github.sha }}"
|
||||
if [ -n "$before" ] && [ "$before" != "0000000000000000000000000000000000000000" ]; then
|
||||
FILES="$(git diff --name-only "$before" "$after")"
|
||||
elif git rev-parse --verify HEAD~1 >/dev/null 2>&1; then
|
||||
FILES="$(git diff --name-only HEAD~1 HEAD)"
|
||||
else
|
||||
set_all_flags_true
|
||||
write_outputs
|
||||
echo "No parent commit — full pipeline (all modules)"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$(printf '%s' "$FILES" | tr -d '[:space:]')" ]; then
|
||||
set_all_flags_true
|
||||
write_outputs
|
||||
echo "Empty diff — full pipeline fallback"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
full_pipeline=false
|
||||
|
||||
while IFS= read -r f || [ -n "${f:-}" ]; do
|
||||
[ -z "${f:-}" ] && continue
|
||||
case "$f" in
|
||||
# CI/CD инфраструктура — все узлы quality gates
|
||||
.gitea/workflows/*|.golangci.yml|.pre-commit-config.yaml|scripts/*)
|
||||
full_pipeline=true
|
||||
;;
|
||||
docs/openapi.yaml|redocly.yaml)
|
||||
openapi=true
|
||||
;;
|
||||
docs/api.md|docs/access.md)
|
||||
openapi=true
|
||||
go=true
|
||||
;;
|
||||
apps/web/README.md|apps/web/components.json|packages/ui/components.json)
|
||||
;;
|
||||
apps/web/*|packages/ui/*|packages/shared/*)
|
||||
web=true
|
||||
;;
|
||||
deploy/bird/*)
|
||||
bird_conf=true
|
||||
go=true
|
||||
;;
|
||||
deploy/compose/*|deploy/docker/*)
|
||||
docker_go=true
|
||||
docker_web=true
|
||||
docker_bird=true
|
||||
go=true
|
||||
;;
|
||||
go.mod|go.sum|go.work)
|
||||
go=true
|
||||
;;
|
||||
migrations/*)
|
||||
go=true
|
||||
;;
|
||||
cmd/*|internal/*|*.go)
|
||||
go=true
|
||||
bird_conf=true
|
||||
;;
|
||||
docs/*)
|
||||
go=true
|
||||
;;
|
||||
package.json|package-lock.json|pnpm-lock.yaml|pnpm-workspace.yaml|.releaserc.json)
|
||||
full_pipeline=true
|
||||
;;
|
||||
*)
|
||||
go=true
|
||||
;;
|
||||
esac
|
||||
done <<< "$FILES"
|
||||
|
||||
if $full_pipeline; then
|
||||
set_all_flags_true
|
||||
fi
|
||||
|
||||
write_outputs
|
||||
|
||||
echo "Changed files (first 30):"
|
||||
printf '%s\n' "$FILES" | head -n 30
|
||||
echo "--- flags ---"
|
||||
echo "openapi=$openapi go=$go web=$web bird_conf=$bird_conf"
|
||||
echo "docker_go=$docker_go docker_web=$docker_web docker_bird=$docker_bird full_pipeline=$full_pipeline"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
openapi:
|
||||
needs: [changes]
|
||||
if: needs.changes.outputs.openapi == 'true' || needs.changes.outputs.web == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: Enable pnpm via corepack
|
||||
run: corepack enable
|
||||
- name: Lint OpenAPI (Redocly)
|
||||
run: npx --yes @redocly/cli@1 lint docs/openapi.yaml
|
||||
- name: Check OpenAPI→TS codegen is fresh
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
pnpm install --frozen-lockfile
|
||||
chmod +x scripts/check-openapi-gen.sh
|
||||
sh scripts/check-openapi-gen.sh
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
web:
|
||||
needs: [changes]
|
||||
if: needs.changes.outputs.web == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: Enable pnpm via corepack
|
||||
run: corepack enable
|
||||
- name: pnpm install, typecheck, lint, test, build
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm --filter @evobgp/web run typecheck
|
||||
pnpm --filter @evobgp/web run lint
|
||||
pnpm --filter @evobgp/web run test
|
||||
pnpm --filter @evobgp/web run build
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
go:
|
||||
needs: [changes]
|
||||
if: needs.changes.outputs.go == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
cache: true
|
||||
cache-dependency-path: go.sum
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
- name: Lint httpapi (ERR-01 / ARCH-01)
|
||||
run: sh scripts/lint-httpapi.sh
|
||||
- name: Check migration pairs (DEP-03)
|
||||
run: sh scripts/check-migrations-pair.sh
|
||||
- name: Validate remote speaker compose
|
||||
run: sh scripts/validate-remote-speaker-compose.sh
|
||||
# go.mod: go 1.24 — бинарник golangci-lint < v1.64.2 (сборка на Go 1.23) не запускается.
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v6
|
||||
with:
|
||||
version: v1.64.8
|
||||
install-mode: goinstall
|
||||
- name: Test
|
||||
run: go test ./... -race -count=1
|
||||
- name: Build all commands
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
out="${RUNNER_TEMP}/evobgp-bin"
|
||||
mkdir -p "$out"
|
||||
for d in cmd/*/; do
|
||||
name="$(basename "$d")"
|
||||
go build -o "$out/$name" "./$d"
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
bird2:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changes, go]
|
||||
if: >-
|
||||
always() &&
|
||||
needs.changes.result == 'success' &&
|
||||
needs.go.result != 'failure' &&
|
||||
(needs.changes.outputs.go == 'true' ||
|
||||
needs.changes.outputs.bird_conf == 'true' ||
|
||||
needs.changes.outputs.docker_bird == 'true' ||
|
||||
needs.changes.outputs.docker_go == 'true')
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install bird2 (репозиторий Ubuntu runner, как в образе evobgp-bird2)
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
if command -v sudo >/dev/null 2>&1; then SUDO=sudo; else SUDO=""; fi
|
||||
$SUDO apt-get update -qq
|
||||
DEBIAN_FRONTEND=noninteractive $SUDO apt-get install -y -qq bird2
|
||||
bird --version
|
||||
- name: bird -p on all scenario bird.conf files
|
||||
env:
|
||||
WORKSPACE: ${{ github.workspace }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
WS="${WORKSPACE:-$PWD}"
|
||||
cd "$WS"
|
||||
if [ ! -f internal/birdfmt/testdata/scenarios/minimal/bird.conf ]; then
|
||||
echo "Нет сценариев BIRD в checkout. Проверьте, что internal/birdfmt/testdata/scenarios закоммичен и push в remote."
|
||||
ls -la internal/birdfmt/testdata/ 2>/dev/null || ls -la
|
||||
exit 1
|
||||
fi
|
||||
for conf in internal/birdfmt/testdata/scenarios/*/bird.conf; do
|
||||
echo "==> $conf"
|
||||
bird -c "$WS/$conf" -p
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
commitlint:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
cache-dependency-path: package-lock.json
|
||||
- name: Lint commit messages
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
npm ci
|
||||
npx commitlint --from "${{ github.event.pull_request.base.sha }}" --to "${{ github.event.pull_request.head.sha }}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Один push в main: semantic-release (тег на текущий commit, без доп. commit) + docker push.
|
||||
# ---------------------------------------------------------------------------
|
||||
release:
|
||||
needs: [changes, openapi, web, go, bird2]
|
||||
if: >-
|
||||
always() &&
|
||||
github.event_name == 'push' &&
|
||||
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') &&
|
||||
needs.changes.result == 'success' &&
|
||||
(needs.openapi.result == 'success' || needs.openapi.result == 'skipped') &&
|
||||
(needs.web.result == 'success' || needs.web.result == 'skipped') &&
|
||||
(needs.go.result == 'success' || needs.go.result == 'skipped') &&
|
||||
(needs.bird2.result == 'success' || needs.bird2.result == 'skipped')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
token: ${{ secrets.ACTIONS_PAT || gitea.token }}
|
||||
persist-credentials: true
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
cache-dependency-path: package-lock.json
|
||||
- name: Install release tooling
|
||||
run: npm ci
|
||||
- name: Verify releasable commit messages
|
||||
run: node scripts/commit/verify-release-commits.mjs
|
||||
- name: Semantic release
|
||||
run: npx semantic-release
|
||||
env:
|
||||
GITEA_URL: https://git.shx.one
|
||||
GITEA_TOKEN: ${{ secrets.ACTIONS_PAT || gitea.token }}
|
||||
- name: Detect new release
|
||||
id: rel
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version=""
|
||||
if [ -f .release-version ]; then
|
||||
version="$(tr -d '[:space:]' < .release-version)"
|
||||
echo "New release from semantic-release: $version"
|
||||
else
|
||||
# Re-run after a failed docker step: tag already exists, successCmd
|
||||
# did not write .release-version (semantic-release is a no-op).
|
||||
git fetch --tags --force origin || true
|
||||
tag="$(git tag --points-at HEAD --list 'v*.*.*' | sort -V | tail -n1 || true)"
|
||||
if [ -n "${tag:-}" ]; then
|
||||
version="${tag#v}"
|
||||
echo "Reuse existing tag $tag on HEAD (release retry)"
|
||||
fi
|
||||
fi
|
||||
if [ -n "${version:-}" ]; then
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
echo "released=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "released=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No releasable commits — skipping image publish"
|
||||
fi
|
||||
- name: Set up Docker Buildx
|
||||
if: steps.rel.outputs.released == 'true'
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Prepare image metadata
|
||||
if: steps.rel.outputs.released == 'true'
|
||||
id: meta
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "version=${{ steps.rel.outputs.version }}" >> "$GITHUB_OUTPUT"
|
||||
owner_lc="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
|
||||
echo "owner_lc=$owner_lc" >> "$GITHUB_OUTPUT"
|
||||
short_sha="$(echo '${{ github.sha }}' | cut -c1-7)"
|
||||
echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT"
|
||||
echo "build_time=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
||||
- name: Log in to Gitea Registry
|
||||
if: steps.rel.outputs.released == 'true'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.shx.one
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.ACTIONS_PAT || gitea.token }}
|
||||
- name: Build and push images (bake)
|
||||
if: steps.rel.outputs.released == 'true'
|
||||
env:
|
||||
REGISTRY: git.shx.one/${{ steps.meta.outputs.owner_lc }}
|
||||
IMAGE_TAG: latest
|
||||
VERSION: ${{ steps.meta.outputs.version }}
|
||||
SHORT_SHA: ${{ steps.meta.outputs.short_sha }}
|
||||
SHA_FULL: ${{ github.sha }}
|
||||
BUILD_TIME: ${{ steps.meta.outputs.build_time }}
|
||||
CACHE_REF_GO: git.shx.one/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:go-buildcache
|
||||
CACHE_REF_WEB: git.shx.one/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:web-buildcache
|
||||
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
cd "${{ github.workspace }}/deploy/docker"
|
||||
sh write-bake-override.sh
|
||||
docker buildx bake --allow=fs.read="${{ github.workspace }}" \
|
||||
-f docker-bake.hcl -f docker-bake.override.hcl default --push
|
||||
|
||||
quality:
|
||||
uses: ./.gitea/workflows/quality.yaml
|
||||
with:
|
||||
is_pull_request: true
|
||||
base_sha: ${{ github.event.pull_request.base.sha }}
|
||||
head_sha: ${{ github.event.pull_request.head.sha }}
|
||||
allow_registry_login: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
|
||||
secrets:
|
||||
ACTIONS_PAT: ${{ secrets.ACTIONS_PAT }}
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
# Quality gates (reusable). Callers: ci.yaml (PR), cd.yaml (push main).
|
||||
name: quality
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
is_pull_request:
|
||||
type: boolean
|
||||
required: true
|
||||
base_sha:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
head_sha:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
before_sha:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
allow_registry_login:
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
secrets:
|
||||
ACTIONS_PAT:
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
openapi: ${{ steps.detect.outputs.openapi }}
|
||||
go: ${{ steps.detect.outputs.go }}
|
||||
web: ${{ steps.detect.outputs.web }}
|
||||
bird_conf: ${{ steps.detect.outputs.bird_conf }}
|
||||
docker_go: ${{ steps.detect.outputs.docker_go }}
|
||||
docker_web: ${{ steps.detect.outputs.docker_web }}
|
||||
docker_bird: ${{ steps.detect.outputs.docker_bird }}
|
||||
steps:
|
||||
- if: ${{ inputs.is_pull_request }}
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- if: ${{ inputs.is_pull_request == false }}
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 2
|
||||
- id: detect
|
||||
name: Detect changed paths per module
|
||||
env:
|
||||
IS_PR: ${{ inputs.is_pull_request }}
|
||||
BASE_SHA: ${{ inputs.base_sha }}
|
||||
HEAD_SHA: ${{ inputs.head_sha }}
|
||||
BEFORE_SHA: ${{ inputs.before_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
openapi=false
|
||||
go=false
|
||||
web=false
|
||||
bird_conf=false
|
||||
docker_go=false
|
||||
docker_web=false
|
||||
docker_bird=false
|
||||
|
||||
set_all_flags_true() {
|
||||
openapi=true
|
||||
go=true
|
||||
web=true
|
||||
bird_conf=true
|
||||
docker_go=true
|
||||
docker_web=true
|
||||
docker_bird=true
|
||||
}
|
||||
|
||||
write_outputs() {
|
||||
for v in openapi go web bird_conf docker_go docker_web docker_bird; do
|
||||
eval "echo \"\$v=\$$v\"" >> "$GITHUB_OUTPUT"
|
||||
done
|
||||
}
|
||||
|
||||
if [ "$IS_PR" = "true" ]; then
|
||||
FILES="$(git diff --name-only "$BASE_SHA" "$HEAD_SHA")"
|
||||
else
|
||||
after="${HEAD_SHA:-$(git rev-parse HEAD)}"
|
||||
before="$BEFORE_SHA"
|
||||
if [ -n "$before" ] && [ "$before" != "0000000000000000000000000000000000000000" ]; then
|
||||
FILES="$(git diff --name-only "$before" "$after")"
|
||||
elif git rev-parse --verify HEAD~1 >/dev/null 2>&1; then
|
||||
FILES="$(git diff --name-only HEAD~1 HEAD)"
|
||||
else
|
||||
set_all_flags_true
|
||||
write_outputs
|
||||
echo "No parent commit — full pipeline (all modules)"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$(printf '%s' "$FILES" | tr -d '[:space:]')" ]; then
|
||||
set_all_flags_true
|
||||
write_outputs
|
||||
echo "Empty diff — full pipeline fallback"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
full_pipeline=false
|
||||
|
||||
while IFS= read -r f || [ -n "${f:-}" ]; do
|
||||
[ -z "${f:-}" ] && continue
|
||||
case "$f" in
|
||||
.gitea/workflows/*|.golangci.yml|.pre-commit-config.yaml|scripts/*)
|
||||
full_pipeline=true
|
||||
;;
|
||||
docs/openapi.yaml|redocly.yaml)
|
||||
openapi=true
|
||||
;;
|
||||
docs/api.md|docs/access.md)
|
||||
openapi=true
|
||||
go=true
|
||||
;;
|
||||
.cursor/*|.claude/*|.codegraph/*|memory-bank/*)
|
||||
;;
|
||||
*.md|AGENTS.md)
|
||||
;;
|
||||
apps/web/README.md|apps/web/components.json|packages/ui/components.json)
|
||||
;;
|
||||
apps/web/*|packages/ui/*|packages/shared/*)
|
||||
web=true
|
||||
;;
|
||||
deploy/bird/*)
|
||||
bird_conf=true
|
||||
go=true
|
||||
;;
|
||||
deploy/compose/*|deploy/docker/*|.dockerignore)
|
||||
docker_go=true
|
||||
docker_web=true
|
||||
docker_bird=true
|
||||
go=true
|
||||
;;
|
||||
go.mod|go.sum|go.work)
|
||||
go=true
|
||||
;;
|
||||
migrations/*)
|
||||
go=true
|
||||
;;
|
||||
cmd/*|internal/*|*.go)
|
||||
go=true
|
||||
bird_conf=true
|
||||
;;
|
||||
docs/*)
|
||||
;;
|
||||
package.json|package-lock.json|pnpm-lock.yaml|pnpm-workspace.yaml|.releaserc.json)
|
||||
full_pipeline=true
|
||||
;;
|
||||
*)
|
||||
;;
|
||||
esac
|
||||
done <<< "$FILES"
|
||||
|
||||
if $full_pipeline; then
|
||||
set_all_flags_true
|
||||
fi
|
||||
|
||||
write_outputs
|
||||
|
||||
echo "Changed files (first 30):"
|
||||
printf '%s\n' "$FILES" | head -n 30
|
||||
echo "--- flags ---"
|
||||
echo "openapi=$openapi go=$go web=$web bird_conf=$bird_conf"
|
||||
echo "docker_go=$docker_go docker_web=$docker_web docker_bird=$docker_bird full_pipeline=$full_pipeline"
|
||||
|
||||
openapi:
|
||||
needs: [changes]
|
||||
if: needs.changes.outputs.openapi == 'true' || needs.changes.outputs.web == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: Export cache paths
|
||||
run: sh scripts/ci/export-cache-env.sh
|
||||
- id: pnpm-hash
|
||||
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
|
||||
- id: pnpm-cache
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||
with:
|
||||
path: |
|
||||
${{ env.PNPM_STORE_DIR }}
|
||||
${{ env.COREPACK_HOME }}
|
||||
node_modules
|
||||
apps/web/node_modules
|
||||
packages/ui/node_modules
|
||||
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
|
||||
restore-keys: |
|
||||
pnpm-${{ runner.os }}-
|
||||
- name: pnpm install, Redocly, codegen check
|
||||
env:
|
||||
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
sh scripts/ci/pnpm-ci.sh
|
||||
pnpm exec redocly lint docs/openapi.yaml
|
||||
chmod +x scripts/check-openapi-gen.sh
|
||||
sh scripts/check-openapi-gen.sh
|
||||
|
||||
web:
|
||||
needs: [changes]
|
||||
if: needs.changes.outputs.web == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: Export cache paths
|
||||
run: sh scripts/ci/export-cache-env.sh
|
||||
- id: pnpm-hash
|
||||
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
|
||||
- id: pnpm-cache
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||
with:
|
||||
path: |
|
||||
${{ env.PNPM_STORE_DIR }}
|
||||
${{ env.COREPACK_HOME }}
|
||||
node_modules
|
||||
apps/web/node_modules
|
||||
packages/ui/node_modules
|
||||
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
|
||||
restore-keys: |
|
||||
pnpm-${{ runner.os }}-
|
||||
- name: pnpm install, typecheck, lint, test, build
|
||||
env:
|
||||
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
sh scripts/ci/pnpm-ci.sh
|
||||
pnpm --filter @evobgp/web run typecheck
|
||||
pnpm --filter @evobgp/web run lint
|
||||
pnpm --filter @evobgp/web run test
|
||||
pnpm --filter @evobgp/web run build
|
||||
|
||||
go:
|
||||
needs: [changes]
|
||||
if: needs.changes.outputs.go == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
|
||||
with:
|
||||
go-version: "1.24"
|
||||
cache: false
|
||||
- name: Export cache paths
|
||||
run: sh scripts/ci/export-cache-env.sh
|
||||
- id: go-hash
|
||||
run: echo "key=$(sha256sum go.sum | awk '{print $1}')" >> "$GITHUB_OUTPUT"
|
||||
- uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||
with:
|
||||
path: |
|
||||
${{ env.GOMODCACHE }}
|
||||
${{ env.GOCACHE }}
|
||||
${{ env.GOBIN }}
|
||||
${{ env.GOLANGCI_LINT_CACHE }}
|
||||
key: go-${{ runner.os }}-1.24-gl1.64.8-${{ steps.go-hash.outputs.key }}
|
||||
restore-keys: |
|
||||
go-${{ runner.os }}-1.24-gl1.64.8-
|
||||
go-${{ runner.os }}-1.24-
|
||||
- name: Download modules
|
||||
env:
|
||||
GOMODCACHE: ${{ env.GOMODCACHE }}
|
||||
GOCACHE: ${{ env.GOCACHE }}
|
||||
run: go mod download
|
||||
- name: Vet
|
||||
env:
|
||||
GOFLAGS: -mod=readonly
|
||||
run: go vet ./...
|
||||
- name: Lint httpapi (ERR-01 / ARCH-01)
|
||||
run: sh scripts/lint-httpapi.sh
|
||||
- name: Check migration pairs (DEP-03)
|
||||
run: sh scripts/check-migrations-pair.sh
|
||||
- name: Validate remote speaker compose
|
||||
run: sh scripts/validate-remote-speaker-compose.sh
|
||||
- name: golangci-lint
|
||||
env:
|
||||
GOLANGCI_LINT_VERSION: v1.64.8
|
||||
run: sh scripts/ci/golangci-lint.sh
|
||||
- name: Test
|
||||
env:
|
||||
GOFLAGS: -mod=readonly
|
||||
GOMODCACHE: ${{ env.GOMODCACHE }}
|
||||
GOCACHE: ${{ env.GOCACHE }}
|
||||
run: go test ./... -race -count=1
|
||||
- name: Build all commands
|
||||
env:
|
||||
GOFLAGS: -mod=readonly
|
||||
GOMODCACHE: ${{ env.GOMODCACHE }}
|
||||
GOCACHE: ${{ env.GOCACHE }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
out="${RUNNER_TEMP}/evobgp-bin"
|
||||
mkdir -p "$out"
|
||||
for d in cmd/*/; do
|
||||
name="$(basename "$d")"
|
||||
go build -o "$out/$name" "./$d"
|
||||
done
|
||||
|
||||
bird2:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changes, go]
|
||||
if: >-
|
||||
always() &&
|
||||
needs.changes.result == 'success' &&
|
||||
needs.go.result != 'failure' &&
|
||||
(needs.changes.outputs.go == 'true' ||
|
||||
needs.changes.outputs.bird_conf == 'true' ||
|
||||
needs.changes.outputs.docker_bird == 'true' ||
|
||||
needs.changes.outputs.docker_go == 'true')
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: Install bird2 (репозиторий Ubuntu runner, как в образе evobgp-bird2)
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
if command -v sudo >/dev/null 2>&1; then SUDO=sudo; else SUDO=""; fi
|
||||
$SUDO apt-get update -qq
|
||||
DEBIAN_FRONTEND=noninteractive $SUDO apt-get install -y -qq bird2
|
||||
bird --version
|
||||
- name: bird -p on all scenario bird.conf files
|
||||
env:
|
||||
WORKSPACE: ${{ github.workspace }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
WS="${WORKSPACE:-$PWD}"
|
||||
cd "$WS"
|
||||
if [ ! -f internal/birdfmt/testdata/scenarios/minimal/bird.conf ]; then
|
||||
echo "Нет сценариев BIRD в checkout. Проверьте, что internal/birdfmt/testdata/scenarios закоммичен и push в remote."
|
||||
ls -la internal/birdfmt/testdata/ 2>/dev/null || ls -la
|
||||
exit 1
|
||||
fi
|
||||
for conf in internal/birdfmt/testdata/scenarios/*/bird.conf; do
|
||||
echo "==> $conf"
|
||||
bird -c "$WS/$conf" -p
|
||||
done
|
||||
|
||||
commitlint:
|
||||
if: inputs.is_pull_request
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: Export cache paths
|
||||
run: sh scripts/ci/export-cache-env.sh
|
||||
- id: pnpm-hash
|
||||
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
|
||||
- id: pnpm-cache
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||
with:
|
||||
path: |
|
||||
${{ env.PNPM_STORE_DIR }}
|
||||
${{ env.COREPACK_HOME }}
|
||||
node_modules
|
||||
apps/web/node_modules
|
||||
packages/ui/node_modules
|
||||
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
|
||||
restore-keys: |
|
||||
pnpm-${{ runner.os }}-
|
||||
- name: Lint commit messages
|
||||
env:
|
||||
BASE_SHA: ${{ inputs.base_sha }}
|
||||
HEAD_SHA: ${{ inputs.head_sha }}
|
||||
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
sh scripts/ci/pnpm-ci.sh
|
||||
pnpm exec commitlint --from "$BASE_SHA" --to "$HEAD_SHA"
|
||||
|
||||
docker-check:
|
||||
needs: [changes]
|
||||
if: >-
|
||||
inputs.is_pull_request &&
|
||||
(needs.changes.outputs.docker_go == 'true' ||
|
||||
needs.changes.outputs.docker_web == 'true' ||
|
||||
needs.changes.outputs.docker_bird == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
with:
|
||||
name: evobgp
|
||||
driver: docker-container
|
||||
cleanup: false
|
||||
- name: Log in to Gitea Registry
|
||||
if: inputs.allow_registry_login
|
||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||
with:
|
||||
registry: git.shx.one
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.ACTIONS_PAT }}
|
||||
- name: bake --print
|
||||
working-directory: deploy/docker
|
||||
env:
|
||||
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
|
||||
BUILDX_BAKE_FILE_RELATIVE_PATHS: "1"
|
||||
run: docker buildx bake --allow=fs.read="${{ github.workspace }}" -f docker-bake.hcl --print default
|
||||
- name: bake (no push)
|
||||
if: inputs.allow_registry_login
|
||||
working-directory: deploy/docker
|
||||
env:
|
||||
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
|
||||
BUILDX_BAKE_FILE_RELATIVE_PATHS: "1"
|
||||
CACHE_REF_GO: git.shx.one/${{ github.repository_owner }}/evobgp-buildcache:go-buildcache
|
||||
CACHE_REF_WEB: git.shx.one/${{ github.repository_owner }}/evobgp-buildcache:web-buildcache
|
||||
CACHE_REF_BIRDC: git.shx.one/${{ github.repository_owner }}/evobgp-buildcache:birdc-buildcache
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
owner_lc="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
|
||||
export CACHE_REF_GO="git.shx.one/${owner_lc}/evobgp-buildcache:go-buildcache"
|
||||
export CACHE_REF_WEB="git.shx.one/${owner_lc}/evobgp-buildcache:web-buildcache"
|
||||
export CACHE_REF_BIRDC="git.shx.one/${owner_lc}/evobgp-buildcache:birdc-buildcache"
|
||||
docker buildx bake --allow=fs.read="${{ github.workspace }}" -f docker-bake.hcl default
|
||||
@@ -5,7 +5,7 @@ user-invocable: false
|
||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||
---
|
||||
|
||||
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
|
||||
# ReUI for Agents
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ReUI components
|
||||
|
||||
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
|
||||
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
||||
|
||||
@@ -106,22 +106,60 @@ Common mistakes:
|
||||
|
||||
## filters
|
||||
|
||||
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
||||
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
const [filters, setFilters] = useState<Filter[]>([
|
||||
createFilter("priority", "is_any_of", ["low"]),
|
||||
])
|
||||
const fields: FilterFieldConfig[] = [
|
||||
{ key: "priority", label: "Priority", type: "multiselect",
|
||||
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
|
||||
const fields: FilterField[] = [
|
||||
{ id: "title", label: "Title", type: "text" },
|
||||
{
|
||||
id: "status",
|
||||
label: "Status",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "archived", label: "Archived" },
|
||||
],
|
||||
},
|
||||
]
|
||||
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||
|
||||
<Filters filters={filters} fields={fields} onChange={setFilters} />
|
||||
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||
```
|
||||
|
||||
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
|
||||
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||
|
||||
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||
|
||||
## cascader
|
||||
|
||||
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||
<CascaderTrigger render={<Button variant="outline" />}>
|
||||
<CascaderValue placeholder="Select an attribute" />
|
||||
</CascaderTrigger>
|
||||
<CascaderContent className="w-80">
|
||||
<CascaderPanel>
|
||||
<CascaderNav>
|
||||
<CascaderBreadcrumb />
|
||||
<CascaderInput />
|
||||
</CascaderNav>
|
||||
<CascaderEmpty />
|
||||
<CascaderList maxHeight={288}>
|
||||
<CascaderItems />
|
||||
</CascaderList>
|
||||
<CascaderStatus />
|
||||
</CascaderPanel>
|
||||
</CascaderContent>
|
||||
</Cascader>
|
||||
```
|
||||
|
||||
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||
|
||||
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||
|
||||
## date-selector
|
||||
|
||||
|
||||
@@ -50,6 +50,6 @@ docker compose --profile reference up -d
|
||||
|
||||
- [docs/releasing.md](docs/releasing.md) — пайплайн, commit conventions, секреты CI
|
||||
- API: `GET /version` и `GET /v1/version` (поле `version`)
|
||||
- Docker-образы: теги `latest`, `vX.Y.Z`, `X.Y.Z` — в **том же CI run**, что и релиз (job `release`)
|
||||
- Docker-образы: теги `latest`, `vX.Y.Z`, `X.Y.Z` — в **том же CD run**, что и релиз (job `publish`)
|
||||
|
||||
Лицензия и условия использования — по политике владельца репозитория.
|
||||
|
||||
@@ -167,7 +167,7 @@ export function DashboardModulesGrid({
|
||||
description="Поиск, сортировка и быстрый переход к настройке"
|
||||
className="min-w-0"
|
||||
actions={
|
||||
<Button variant="outline" size="sm" render={<Link to="/modules/new" />}>
|
||||
<Button variant="outline" size="sm" render={<Link to="/modules" search={{ create: true }} />}>
|
||||
<PlusIcon />
|
||||
Создать
|
||||
</Button>
|
||||
|
||||
@@ -16,7 +16,8 @@ const ACTIONS: QuickActionItem[] = [
|
||||
id: 'new-module',
|
||||
title: 'Создать модуль',
|
||||
description: 'Новый модуль маршрутизации и источники префиксов.',
|
||||
to: '/modules/new',
|
||||
to: '/modules',
|
||||
search: { create: true },
|
||||
icon: <Plus aria-hidden />,
|
||||
iconClassName: 'text-primary',
|
||||
},
|
||||
|
||||
@@ -29,7 +29,6 @@ export function DashboardRecentJobsGrid({
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell
|
||||
title={jobKindRu(row.original.kind)}
|
||||
accent="mono"
|
||||
subtitle={
|
||||
row.original.meta?.module_id
|
||||
? (nameById.get(String(row.original.meta.module_id)) ?? undefined)
|
||||
|
||||
@@ -128,7 +128,7 @@ export function LookupAddStep({
|
||||
<AlertTitle>Нет подходящего модуля</AlertTitle>
|
||||
<AlertDescription>
|
||||
Создайте модуль типа {wantedType}, затем повторите добавление.{' '}
|
||||
<Button variant="link" size="sm" className="h-auto p-0" render={<Link to="/modules/new" />}>
|
||||
<Button variant="link" size="sm" className="h-auto p-0" render={<Link to="/modules" search={{ create: true }} />}>
|
||||
Перейти к модулям
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
import { useEffect, 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 { Label } from '@evobgp/ui/components/label'
|
||||
|
||||
import { FormDrawer } from '@/components/form-drawer'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { CommunitySelect } from '@/components/modules/community-select'
|
||||
import { SelectField } from '@/components/select-field'
|
||||
import { dohProfileShortLabel } from '@/lib/modules/helpers'
|
||||
import { dohPolicyRu, moduleTypeRu } from '@/lib/ui-labels'
|
||||
import { useCreateModuleMutation } from '@/queries/modules'
|
||||
import type {
|
||||
BgpCommunity,
|
||||
DohProfile,
|
||||
DohResolverPolicy,
|
||||
ModuleCreate,
|
||||
ModuleRow,
|
||||
ModuleType,
|
||||
} from '@/types/api'
|
||||
|
||||
interface ModuleCreateDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
communities: BgpCommunity[]
|
||||
dohProfiles: DohProfile[]
|
||||
onCreated?: (mod: ModuleRow) => void
|
||||
}
|
||||
|
||||
/** @see https://reui.io/preview/base/form-7 */
|
||||
/** @see https://reui.io/preview/base/sheet-8 */
|
||||
|
||||
const MODULE_TYPE_ITEMS: { value: ModuleType; label: string }[] = [
|
||||
{ value: 'IP_RANGES', label: moduleTypeRu('IP_RANGES') },
|
||||
{ value: 'AS_PREFIXES', label: moduleTypeRu('AS_PREFIXES') },
|
||||
{ value: 'CDN_CIDRS', label: moduleTypeRu('CDN_CIDRS') },
|
||||
{ value: 'DOMAINS', label: moduleTypeRu('DOMAINS') },
|
||||
]
|
||||
|
||||
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 ModuleCreateDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
communities,
|
||||
dohProfiles,
|
||||
onCreated,
|
||||
}: ModuleCreateDialogProps) {
|
||||
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'
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setType('IP_RANGES')
|
||||
setName('')
|
||||
setEnabled(true)
|
||||
setPriority('0')
|
||||
setRefreshIntervalSec('')
|
||||
setCronExpr('')
|
||||
setDefaultCommunityId(null)
|
||||
setDohResolverPolicy('primary_only')
|
||||
setDohProfileIds([])
|
||||
}, [open])
|
||||
|
||||
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 save() {
|
||||
const trimmedName = name.trim()
|
||||
if (!trimmedName) {
|
||||
toast.error('Укажите название модуля')
|
||||
return
|
||||
}
|
||||
|
||||
const priorityNum = Number(priority)
|
||||
if (!Number.isFinite(priorityNum) || !Number.isInteger(priorityNum)) {
|
||||
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: priorityNum,
|
||||
}
|
||||
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)
|
||||
onOpenChange(false)
|
||||
onCreated?.(created)
|
||||
} catch {
|
||||
// toast in mutation
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormDrawer
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Новый модуль"
|
||||
description="Тип задаётся один раз. Записи добавляются на карточке модуля."
|
||||
className="sm:max-w-md"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<LoadingButton type="button" loading={createMutation.isPending} onClick={() => void save()}>
|
||||
Создать
|
||||
</LoadingButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<SelectField
|
||||
id="mod-create-type"
|
||||
label="Тип"
|
||||
items={MODULE_TYPE_ITEMS}
|
||||
value={type}
|
||||
onValueChange={(v) => {
|
||||
if (v) setType(v)
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="mod-create-name">Название</Label>
|
||||
<Input
|
||||
id="mod-create-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Имя модуля"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center justify-between gap-4 rounded-lg border border-border bg-muted/30 p-3">
|
||||
<div className="grid min-w-0 flex-1 gap-1 pr-2">
|
||||
<Label htmlFor="mod-create-enabled">Включён</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Выключенный модуль не участвует в обновлении и применении.
|
||||
</p>
|
||||
</div>
|
||||
<Checkbox
|
||||
id="mod-create-enabled"
|
||||
checked={enabled}
|
||||
onCheckedChange={(v) => setEnabled(v === true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="mod-create-priority">Приоритет</Label>
|
||||
<Input
|
||||
id="mod-create-priority"
|
||||
type="number"
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="mod-create-interval">Интервал обновления (сек)</Label>
|
||||
<Input
|
||||
id="mod-create-interval"
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="пусто = по умолчанию"
|
||||
value={refreshIntervalSec}
|
||||
onChange={(e) => setRefreshIntervalSec(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="mod-create-cron">Cron (опционально)</Label>
|
||||
<Input
|
||||
id="mod-create-cron"
|
||||
placeholder="0 * * * *"
|
||||
value={cronExpr}
|
||||
onChange={(e) => setCronExpr(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CommunitySelect
|
||||
id="mod-create-community"
|
||||
label="Community по умолчанию"
|
||||
value={defaultCommunityId}
|
||||
onValueChange={setDefaultCommunityId}
|
||||
communities={communities}
|
||||
nullable
|
||||
/>
|
||||
|
||||
{isDomains ? (
|
||||
<>
|
||||
<SelectField
|
||||
id="mod-create-doh-policy"
|
||||
label="Политика DoH"
|
||||
items={DOH_POLICY_ITEMS}
|
||||
value={dohResolverPolicy}
|
||||
onValueChange={(v) => {
|
||||
if (v) setDohResolverPolicy(v)
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>DoH профили</Label>
|
||||
{dohProfiles.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">Нет профилей в справочнике</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 rounded-lg border border-border p-3">
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</FormDrawer>
|
||||
)
|
||||
}
|
||||
@@ -36,9 +36,8 @@ const SPEAKER_TABS = [
|
||||
]
|
||||
|
||||
const ROLE_OPTIONS = [
|
||||
{ value: 'primary', label: 'Основной' },
|
||||
{ value: 'secondary', label: 'Резервный' },
|
||||
{ value: 'speaker', label: 'Спикер' },
|
||||
{ value: 'replica', label: 'Реплика' },
|
||||
{ value: 'master', label: 'Мастер' },
|
||||
]
|
||||
|
||||
function createDefaultSpeakerFilters(): Filter[] {
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Copy, TriangleAlert } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Input } from '@evobgp/ui/components/input'
|
||||
import { Label } from '@evobgp/ui/components/label'
|
||||
import { Textarea } from '@evobgp/ui/components/textarea'
|
||||
|
||||
import { FormDrawer } from '@/components/form-drawer'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { SelectField } from '@/components/select-field'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
import { useCreateSpeakerMutation } from '@/queries/network'
|
||||
import type { BgpSpeakerCreate } from '@/types/api'
|
||||
import type { BgpSpeakerCreate, SpeakerRow } from '@/types/api'
|
||||
|
||||
interface SpeakerFormDialogProps {
|
||||
open: boolean
|
||||
@@ -35,8 +39,20 @@ function buildMetaJson(agentDomain: string, nodeIpv4: string, bgpSource: string)
|
||||
return JSON.stringify(meta)
|
||||
}
|
||||
|
||||
function tlsIncomplete(
|
||||
agentDomain: string,
|
||||
letsencryptEmail: string,
|
||||
cfToken: string,
|
||||
panelIP: string,
|
||||
): boolean {
|
||||
return !agentDomain.trim() || !letsencryptEmail.trim() || !cfToken.trim() || !panelIP.trim()
|
||||
}
|
||||
|
||||
export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps) {
|
||||
const createMutation = useCreateSpeakerMutation()
|
||||
const { isCopied, copyToClipboard } = useCopyToClipboard({
|
||||
onCopy: () => toast.success('Команда скопирована'),
|
||||
})
|
||||
|
||||
const [endpoint, setEndpoint] = useState('')
|
||||
const [role, setRole] = useState('replica')
|
||||
@@ -44,6 +60,13 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps
|
||||
const [nodeIpv4, setNodeIpv4] = useState('')
|
||||
const [bgpSourceIpv4, setBgpSourceIpv4] = useState('')
|
||||
const [bgpSourceManual, setBgpSourceManual] = useState(false)
|
||||
const [letsencryptEmail, setLetsencryptEmail] = useState('')
|
||||
const [cfDnsToken, setCfDnsToken] = useState('')
|
||||
const [panelIP, setPanelIP] = useState('')
|
||||
const [created, setCreated] = useState<SpeakerRow | null>(null)
|
||||
|
||||
const isReplica = role === 'replica'
|
||||
const installCommands = created?.install?.docker_commands ?? ''
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
@@ -53,6 +76,10 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps
|
||||
setNodeIpv4('')
|
||||
setBgpSourceIpv4('')
|
||||
setBgpSourceManual(false)
|
||||
setLetsencryptEmail('')
|
||||
setCfDnsToken('')
|
||||
setPanelIP('')
|
||||
setCreated(null)
|
||||
}, [open])
|
||||
|
||||
function handleEndpointChange(value: string) {
|
||||
@@ -81,82 +108,211 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps
|
||||
endpoint: ep,
|
||||
role: role.trim() || 'replica',
|
||||
meta_json: buildMetaJson(agentDomain, nodeIpv4, bgpSourceIpv4),
|
||||
control_plane_url: window.location.origin,
|
||||
}
|
||||
if (isReplica) {
|
||||
if (letsencryptEmail.trim()) body.letsencrypt_email = letsencryptEmail.trim()
|
||||
if (cfDnsToken.trim()) body.cf_dns_api_token = cfDnsToken.trim()
|
||||
if (panelIP.trim()) body.panel_ip_whitelist = panelIP.trim()
|
||||
}
|
||||
try {
|
||||
await createMutation.mutateAsync(body)
|
||||
const row = await createMutation.mutateAsync(body)
|
||||
if (row.install?.docker_commands) {
|
||||
setCreated(row)
|
||||
return
|
||||
}
|
||||
onOpenChange(false)
|
||||
} catch {
|
||||
// toast in mutation
|
||||
}
|
||||
}
|
||||
|
||||
const showingInstall = created !== null && Boolean(installCommands)
|
||||
|
||||
return (
|
||||
<FormDrawer
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Новый спикер"
|
||||
description="BIRD-агент на ноде реплики или плоскости управления"
|
||||
className="sm:max-w-md"
|
||||
title={showingInstall ? 'Установка на ноду' : 'Новый спикер'}
|
||||
description={
|
||||
showingInstall
|
||||
? 'Секреты показываются один раз. Скопируйте команду на VPS реплики.'
|
||||
: 'BIRD-агент на ноде реплики или плоскости управления'
|
||||
}
|
||||
className={showingInstall ? 'sm:max-w-2xl' : 'sm:max-w-md'}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<LoadingButton type="button" loading={createMutation.isPending} onClick={save}>
|
||||
Создать
|
||||
</LoadingButton>
|
||||
</>
|
||||
showingInstall ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={() => copyToClipboard(installCommands)}
|
||||
>
|
||||
<Copy />
|
||||
{isCopied ? 'Скопировано' : 'Копировать команду'}
|
||||
</Button>
|
||||
<Button type="button" onClick={() => onOpenChange(false)}>
|
||||
Готово
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<LoadingButton type="button" loading={createMutation.isPending} onClick={save}>
|
||||
Создать
|
||||
</LoadingButton>
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="speaker-endpoint">Конечная точка</Label>
|
||||
<Input
|
||||
id="speaker-endpoint"
|
||||
placeholder="https://node.example.com:8443"
|
||||
value={endpoint}
|
||||
onChange={(e) => handleEndpointChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<SelectField
|
||||
id="speaker-role"
|
||||
label="Роль"
|
||||
items={[
|
||||
{ value: 'replica', label: 'Реплика' },
|
||||
{ value: 'master', label: 'Мастер (плоскость)' },
|
||||
]}
|
||||
value={role}
|
||||
onValueChange={(v) => setRole(v ?? 'replica')}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="speaker-agent-domain">Домен агента</Label>
|
||||
<Input
|
||||
id="speaker-agent-domain"
|
||||
placeholder="bird-agent.example.com"
|
||||
value={agentDomain}
|
||||
onChange={(e) => setAgentDomain(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="speaker-node-ipv4">IPv4 ноды</Label>
|
||||
<Input
|
||||
id="speaker-node-ipv4"
|
||||
placeholder="203.0.113.10"
|
||||
value={nodeIpv4}
|
||||
onChange={(e) => handleNodeIpv4Change(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="speaker-bgp-source">Исходный IPv4 BGP</Label>
|
||||
<Input
|
||||
id="speaker-bgp-source"
|
||||
placeholder="203.0.113.10"
|
||||
value={bgpSourceIpv4}
|
||||
onChange={(e) => {
|
||||
setBgpSourceManual(true)
|
||||
setBgpSourceIpv4(e.target.value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{created && installCommands ? (
|
||||
<SpeakerInstallStep created={created} commands={installCommands} />
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="speaker-endpoint">Конечная точка</Label>
|
||||
<Input
|
||||
id="speaker-endpoint"
|
||||
placeholder="https://node.example.com"
|
||||
value={endpoint}
|
||||
onChange={(e) => handleEndpointChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<SelectField
|
||||
id="speaker-role"
|
||||
label="Роль"
|
||||
items={[
|
||||
{ value: 'replica', label: 'Реплика' },
|
||||
{ value: 'master', label: 'Мастер (плоскость)' },
|
||||
]}
|
||||
value={role}
|
||||
onValueChange={(v) => setRole(v ?? 'replica')}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="speaker-agent-domain">Домен агента</Label>
|
||||
<Input
|
||||
id="speaker-agent-domain"
|
||||
placeholder="bird-agent.example.com"
|
||||
value={agentDomain}
|
||||
onChange={(e) => setAgentDomain(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="speaker-node-ipv4">IPv4 ноды</Label>
|
||||
<Input
|
||||
id="speaker-node-ipv4"
|
||||
placeholder="203.0.113.10"
|
||||
value={nodeIpv4}
|
||||
onChange={(e) => handleNodeIpv4Change(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="speaker-bgp-source">Исходный IPv4 BGP</Label>
|
||||
<Input
|
||||
id="speaker-bgp-source"
|
||||
placeholder="203.0.113.10"
|
||||
value={bgpSourceIpv4}
|
||||
onChange={(e) => {
|
||||
setBgpSourceManual(true)
|
||||
setBgpSourceIpv4(e.target.value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{isReplica ? (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="speaker-le-email">Email Let's Encrypt</Label>
|
||||
<Input
|
||||
id="speaker-le-email"
|
||||
type="email"
|
||||
placeholder="ops@example.com"
|
||||
value={letsencryptEmail}
|
||||
onChange={(e) => setLetsencryptEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="speaker-cf-token">Cloudflare DNS API token</Label>
|
||||
<Input
|
||||
id="speaker-cf-token"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
placeholder="Zone:DNS:Edit"
|
||||
value={cfDnsToken}
|
||||
onChange={(e) => setCfDnsToken(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="speaker-panel-ip">IP панели (whitelist)</Label>
|
||||
<Input
|
||||
id="speaker-panel-ip"
|
||||
placeholder="203.0.113.1/32"
|
||||
value={panelIP}
|
||||
onChange={(e) => setPanelIP(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{tlsIncomplete(agentDomain, letsencryptEmail, cfDnsToken, panelIP) ? (
|
||||
<Alert variant="warning">
|
||||
<TriangleAlert />
|
||||
<AlertTitle>Traefik не выпустит сертификат</AlertTitle>
|
||||
<AlertDescription>
|
||||
Нужны домен агента, email LE, Cloudflare token и IP панели. Иначе в
|
||||
команде останутся плейсхолдеры CHANGE_ME_*.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</FormDrawer>
|
||||
)
|
||||
}
|
||||
|
||||
function SpeakerInstallStep({
|
||||
created,
|
||||
commands,
|
||||
}: {
|
||||
created: SpeakerRow
|
||||
commands: string
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Alert variant="warning">
|
||||
<TriangleAlert />
|
||||
<AlertTitle>Сохраните сейчас</AlertTitle>
|
||||
<AlertDescription>
|
||||
agent_secret и node_token больше не будут показаны. Traefik на ноде выпускает
|
||||
сертификат через DNS-01 (Cloudflare). MikroTik стучится на IP ноды:179; 80/443 —
|
||||
только агент панели. Логи: docker compose logs -f bird2 evobgp-agent.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label>ID спикера</Label>
|
||||
<code className="break-all font-mono text-xs">{created.id}</code>
|
||||
</div>
|
||||
{created.agent_secret ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label>agent_secret</Label>
|
||||
<code className="break-all font-mono text-xs">{created.agent_secret}</code>
|
||||
</div>
|
||||
) : null}
|
||||
{created.node_token ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label>node_token</Label>
|
||||
<code className="break-all font-mono text-xs">{created.node_token}</code>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="speaker-docker-commands">Docker-команды</Label>
|
||||
<Textarea
|
||||
id="speaker-docker-commands"
|
||||
readOnly
|
||||
value={commands}
|
||||
className="min-h-64 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -43,7 +43,6 @@ export function OperationsJobsGrid({
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell
|
||||
title={jobKindRu(row.original.kind)}
|
||||
accent="mono"
|
||||
subtitle={
|
||||
row.original.meta?.module_id
|
||||
? (nameById.get(String(row.original.meta.module_id)) ??
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Boxes, Plus } from 'lucide-react'
|
||||
|
||||
import { IllustratedEmptyState } from '@/components/patterns/illustrated-empty-state'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
/** empty-state-3 pattern for first module. */
|
||||
export function ProjectsEmptyState() {
|
||||
export function ProjectsEmptyState({
|
||||
canCreate = true,
|
||||
onCreate,
|
||||
}: {
|
||||
canCreate?: boolean
|
||||
onCreate?: () => void
|
||||
}) {
|
||||
return (
|
||||
<IllustratedEmptyState
|
||||
icon={Boxes}
|
||||
title="Создайте первый модуль"
|
||||
description="Модули задают источники префиксов: AS, CDN, домены и IP-диапазоны."
|
||||
action={
|
||||
<Button size="sm" render={<Link to="/modules/new" />}>
|
||||
<Plus />
|
||||
Новый модуль
|
||||
</Button>
|
||||
canCreate && onCreate ? (
|
||||
<Button size="sm" onClick={onCreate}>
|
||||
<Plus />
|
||||
Новый модуль
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
SelectValue,
|
||||
} from '@evobgp/ui/components/select'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import { jobKindRu } from '@/lib/ui-labels'
|
||||
import { isRefreshJobKind, jobKindRu } from '@/lib/ui-labels'
|
||||
import type { JobRow } from '@/types/api'
|
||||
|
||||
import { ScheduleCalendarView } from './schedule-calendar-view'
|
||||
@@ -34,7 +34,7 @@ function jobTimestamp(job: JobRow): string | undefined {
|
||||
}
|
||||
|
||||
function matchesFilter(job: JobRow, filter: JobFilter): boolean {
|
||||
if (filter === 'refresh') return job.kind === 'module_refresh'
|
||||
if (filter === 'refresh') return isRefreshJobKind(job.kind)
|
||||
if (filter === 'failed')
|
||||
return ['failed', 'error', 'cancelled'].includes(job.status.toLowerCase())
|
||||
return true
|
||||
|
||||
@@ -4,6 +4,7 @@ import { FrameDataGrid } from '@/components/reui-kit'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||
import { isRefreshJobKind } from '@/lib/ui-labels'
|
||||
import type { JobRow } from '@/types/api'
|
||||
|
||||
import { ScheduleJobsGrid } from './schedule-jobs-grid'
|
||||
@@ -11,7 +12,7 @@ import { ScheduleJobsGrid } from './schedule-jobs-grid'
|
||||
type JobTab = 'all' | 'refresh' | 'failed'
|
||||
|
||||
function filterJobs(items: JobRow[], tab: JobTab): JobRow[] {
|
||||
if (tab === 'refresh') return items.filter((j) => j.kind === 'module_refresh')
|
||||
if (tab === 'refresh') return items.filter((j) => isRefreshJobKind(j.kind))
|
||||
if (tab === 'failed')
|
||||
return items.filter((j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()))
|
||||
return items
|
||||
@@ -20,7 +21,7 @@ function filterJobs(items: JobRow[], tab: JobTab): JobRow[] {
|
||||
function tabCounts(items: JobRow[]) {
|
||||
return {
|
||||
all: items.length,
|
||||
refresh: items.filter((j) => j.kind === 'module_refresh').length,
|
||||
refresh: items.filter((j) => isRefreshJobKind(j.kind)).length,
|
||||
failed: items.filter((j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()))
|
||||
.length,
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ export function ScheduleJobsGrid({
|
||||
{
|
||||
accessorKey: 'kind',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />,
|
||||
cell: ({ row }) => <DataGridPrimaryCell title={jobKindRu(row.original.kind)} accent="mono" />,
|
||||
cell: ({ row }) => <DataGridPrimaryCell title={jobKindRu(row.original.kind)} />,
|
||||
meta: { headerTitle: 'Вид' },
|
||||
},
|
||||
{
|
||||
|
||||
@@ -30,9 +30,28 @@ export function moduleTypeRu(type: string): string {
|
||||
|
||||
const JOB_KIND_RU: Record<string, string> = {
|
||||
module_refresh: 'Обновление модуля',
|
||||
tenant_refresh: 'Обновление тенанта',
|
||||
peer_reconcile: 'Согласование пиров',
|
||||
deploy_apply: 'Применение на спикеры',
|
||||
apply: 'Применение конфигурации',
|
||||
revision_rollback: 'Откат ревизии',
|
||||
rollback: 'Откат ревизии',
|
||||
bird_reload: 'Перезагрузка BIRD',
|
||||
postgres_metrics_refresh: 'Метрики PostgreSQL',
|
||||
postgres_slow_query_aggregate: 'Медленные запросы PostgreSQL',
|
||||
postgres_table_bloat_estimate: 'Bloat таблиц PostgreSQL',
|
||||
postgres_index_usage_analyze: 'Использование индексов PostgreSQL',
|
||||
postgres_autovacuum_lag_detect: 'Отставание autovacuum',
|
||||
postgres_vacuum: 'VACUUM PostgreSQL',
|
||||
postgres_vacuum_analyze: 'VACUUM ANALYZE PostgreSQL',
|
||||
postgres_analyze: 'ANALYZE PostgreSQL',
|
||||
postgres_reindex: 'REINDEX PostgreSQL',
|
||||
postgres_cleanup: 'Очистка PostgreSQL',
|
||||
maintenance_policy_run: 'Политика обслуживания',
|
||||
}
|
||||
|
||||
export function isRefreshJobKind(kind: string): boolean {
|
||||
return kind === 'module_refresh' || kind === 'tenant_refresh'
|
||||
}
|
||||
|
||||
export function jobKindRu(kind: string): string {
|
||||
@@ -86,9 +105,10 @@ export function bgpSessionStateRu(state: string | null | undefined): string {
|
||||
export function speakerRoleRu(role: string | null | undefined): string {
|
||||
switch (role) {
|
||||
case 'master':
|
||||
return 'Основной'
|
||||
case 'primary':
|
||||
return 'Основной'
|
||||
case 'replica':
|
||||
return 'Реплика'
|
||||
case 'secondary':
|
||||
return 'Резервный'
|
||||
case 'speaker':
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Link, createFileRoute } from '@tanstack/react-router'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Plus, RefreshCw } from 'lucide-react'
|
||||
|
||||
@@ -6,18 +6,45 @@ import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
import { FrameDataGrid } from '@/components/reui-kit'
|
||||
import { ProjectsEmptyState } from '@/components/patterns/projects-empty-state'
|
||||
import { ModuleCreateDialog } from '@/components/modules/module-create-dialog'
|
||||
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 {
|
||||
directoriesCommunitiesQueryOptions,
|
||||
directoriesDohQueryOptions,
|
||||
} from '@/queries/directories'
|
||||
import { modulesListQueryOptions } from '@/queries/modules'
|
||||
|
||||
function parseCreateFlag(value: unknown): boolean {
|
||||
return value === true || value === '1' || value === 'true'
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/_auth/modules/')({
|
||||
component: ModulesListComponent,
|
||||
validateSearch: (search: Record<string, unknown>): { create?: boolean } => {
|
||||
if (parseCreateFlag(search.create)) return { create: true }
|
||||
return {}
|
||||
},
|
||||
})
|
||||
|
||||
function ModulesListComponent() {
|
||||
const { create } = Route.useSearch()
|
||||
const navigate = Route.useNavigate()
|
||||
const query = useQuery(modulesListQueryOptions())
|
||||
const sessionQ = useQuery(authSessionQueryOptions())
|
||||
const canWrite = sessionCanWriteModules(sessionQ.data)
|
||||
const communitiesQ = useQuery(directoriesCommunitiesQueryOptions())
|
||||
const dohQ = useQuery(directoriesDohQueryOptions())
|
||||
|
||||
const createOpen = canWrite && create === true
|
||||
|
||||
function setCreateOpen(open: boolean) {
|
||||
void navigate({ search: open ? { create: true } : {}, replace: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
@@ -42,10 +69,12 @@ function ModulesListComponent() {
|
||||
<FrameDataGrid
|
||||
title="Все модули"
|
||||
actions={
|
||||
<Button size="sm" render={<Link to="/modules/new" />}>
|
||||
<Plus />
|
||||
Создать
|
||||
</Button>
|
||||
canWrite ? (
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<Plus />
|
||||
Создать
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<QueryState
|
||||
@@ -54,7 +83,12 @@ function ModulesListComponent() {
|
||||
isError={query.isError}
|
||||
error={query.error}
|
||||
empty={query.data?.items?.length === 0}
|
||||
emptyContent={<ProjectsEmptyState />}
|
||||
emptyContent={
|
||||
<ProjectsEmptyState
|
||||
canCreate={canWrite}
|
||||
onCreate={() => setCreateOpen(true)}
|
||||
/>
|
||||
}
|
||||
skeleton={<TableSkeleton rows={6} cols={5} />}
|
||||
onRetry={() => query.refetch()}
|
||||
>
|
||||
@@ -66,6 +100,16 @@ function ModulesListComponent() {
|
||||
)}
|
||||
</QueryState>
|
||||
</FrameDataGrid>
|
||||
|
||||
<ModuleCreateDialog
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
communities={communitiesQ.data?.items ?? []}
|
||||
dohProfiles={dohQ.data?.items ?? []}
|
||||
onCreated={(mod) => {
|
||||
void navigate({ to: '/modules/$moduleId', params: { moduleId: mod.id } })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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, redirect } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/_auth/modules/new')({
|
||||
component: NewModuleComponent,
|
||||
beforeLoad: () => {
|
||||
throw redirect({ to: '/modules', search: { create: true } })
|
||||
},
|
||||
})
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -763,7 +763,7 @@ export interface paths {
|
||||
put?: never;
|
||||
/**
|
||||
* Зарегистрировать спикер
|
||||
* @description Реплика, canary и т.д.
|
||||
* @description Реплика или master. Для replica 201 содержит agent_secret, node_token и install.docker_commands (bird2 + agent + Traefik LE DNS-01) — один раз.
|
||||
*/
|
||||
post: operations["createSpeaker"];
|
||||
delete?: never;
|
||||
@@ -2733,11 +2733,38 @@ export interface components {
|
||||
role: string;
|
||||
/** @description URL agent или https://AGENT_DOMAIN */
|
||||
endpoint: string;
|
||||
/** @description JSON-объект. Ключи node_ipv4, bird_bgp_source_ipv4 (default = node_ipv4), agent_domain, agent_secret (генерируется при создании если пуст). */
|
||||
meta_json?: string;
|
||||
/** @description JSON-объект (строка или object). Ключи node_ipv4, bird_bgp_source_ipv4 (default = node_ipv4), agent_domain, agent_secret (генерируется при создании если пуст). */
|
||||
meta_json?: string | Record<string, never>;
|
||||
/** @description Email ACME для Traefik на ноде. Только для генерации install.docker_commands, не сохраняется. */
|
||||
letsencrypt_email?: string;
|
||||
/** @description Cloudflare DNS API token (Zone:DNS:Edit) для LE DNS-01. Только для install-сниппета, не сохраняется. */
|
||||
cf_dns_api_token?: string;
|
||||
/** @description CIDR/IP панели для Traefik ipallowlist. Только для install-сниппета, не сохраняется. */
|
||||
panel_ip_whitelist?: string;
|
||||
/**
|
||||
* Format: uri
|
||||
* @description Публичный HTTPS URL панели (EVOBGP_CONTROL_PLANE_URL на реплике). Если пуст — из Origin / X-Forwarded-Host.
|
||||
*/
|
||||
control_plane_url?: string;
|
||||
} & {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
/** @description Одноразовый пакет установки реплики (только POST /v1/speakers 201). */
|
||||
SpeakerInstall: {
|
||||
/** @description Bash: sysctl, heredoc docker-compose.yaml (bird2 + agent + Traefik DNS-01) и docker compose up -d. */
|
||||
docker_commands?: string;
|
||||
/** @description Тело docker-compose.yaml без heredoc (превью). */
|
||||
compose_yaml?: string;
|
||||
};
|
||||
BgpSpeakerCreated: components["schemas"]["BgpSpeaker"] & {
|
||||
/** @description Bearer для Panel→Node (EVOBGP_AGENT_SECRET). Только в 201. */
|
||||
agent_secret?: string;
|
||||
/** @description API-ключ role=node (EVOBGP_NODE_TOKEN). Только в 201. */
|
||||
node_token?: string;
|
||||
/** @description Ed25519 pubkey для verify-bundle на ноде. */
|
||||
bundle_pubkey_base64?: string;
|
||||
install?: components["schemas"]["SpeakerInstall"];
|
||||
};
|
||||
BgpSpeakerPatch: {
|
||||
role?: string;
|
||||
endpoint?: string;
|
||||
@@ -4576,13 +4603,13 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Ресурс создан. */
|
||||
/** @description Ресурс создан. Для replica — одноразовый install-сниппет. */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["BgpSpeaker"];
|
||||
"application/json": components["schemas"]["BgpSpeakerCreated"];
|
||||
};
|
||||
};
|
||||
default: components["responses"]["DefaultProblem"];
|
||||
|
||||
@@ -293,13 +293,24 @@ export type SpeakerRow = {
|
||||
last_dispatch_error?: string | null
|
||||
meta_json?: Record<string, unknown>
|
||||
agent_secret?: string
|
||||
node_token?: string
|
||||
bundle_pubkey_base64?: string
|
||||
install?: SpeakerInstall
|
||||
live?: SpeakerLiveStatus
|
||||
}
|
||||
export type SpeakerInstall = {
|
||||
docker_commands?: string
|
||||
compose_yaml?: string
|
||||
}
|
||||
export type SpeakersResponse = Page<SpeakerRow>
|
||||
export type BgpSpeakerCreate = {
|
||||
endpoint: string
|
||||
role?: string
|
||||
meta_json?: string
|
||||
meta_json?: string | Record<string, string>
|
||||
letsencrypt_email?: string
|
||||
cf_dns_api_token?: string
|
||||
panel_ip_whitelist?: string
|
||||
control_plane_url?: string
|
||||
}
|
||||
export type BgpSpeakerPatch = Partial<BgpSpeakerCreate>
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,5 +1,6 @@
|
||||
# Default BIRD 2 config for EvoBGP Docker stack (operator extends with include "bird.d/*.conf";).
|
||||
router id 192.0.2.1;
|
||||
log stderr all;
|
||||
|
||||
protocol device {
|
||||
}
|
||||
|
||||
@@ -7,9 +7,12 @@
|
||||
# --env-file .env.remote-speaker --env-file .env.remote-speaker-tls up -d
|
||||
#
|
||||
# Profiles:
|
||||
# production (default) — bird2 host + agent + evobgp-edge
|
||||
# production (default) — bird2 (speaker-net, 179:179) + agent + evobgp-edge
|
||||
# plain — bird2 + agent без Traefik (lab)
|
||||
# fallback — + sync-bundle polling
|
||||
#
|
||||
# BGP TCP/179 as on the control plane. Overlay sets router id / local.
|
||||
# Logs: docker compose logs -f bird2 evobgp-agent
|
||||
|
||||
name: evobgp-remote-speaker
|
||||
|
||||
@@ -24,13 +27,18 @@ services:
|
||||
profiles: ["production", "plain", "fallback"]
|
||||
image: ${EVOBGP_REGISTRY:-git.shx.one/denozord}/evobgp-bird2:${EVOBGP_IMAGE_TAG:-latest}
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
# sysctls нельзя с network_mode: host — включите ip_forward на VPS (см. docs/remote-speakers.md)
|
||||
sysctls:
|
||||
net.ipv4.ip_forward: "1"
|
||||
net.ipv6.conf.all.forwarding: "1"
|
||||
ports:
|
||||
- "179:179/tcp"
|
||||
volumes:
|
||||
- bird_etc:/etc/bird
|
||||
- bird_run:/run/bird
|
||||
networks:
|
||||
- speaker-net
|
||||
logging: *default-logging
|
||||
|
||||
evobgp-agent:
|
||||
|
||||
+36
-23
@@ -2,29 +2,38 @@
|
||||
|
||||
## CI (Gitea Actions)
|
||||
|
||||
Сборка образов — **`docker buildx bake`** (`deploy/docker/docker-bake.hcl`), не отдельные `docker build`.
|
||||
Сборка образов — **`docker buildx bake`** ([docker-bake.hcl](docker-bake.hcl)), не отдельные `docker build`.
|
||||
|
||||
**Publish:** push в `main` после quality gates — job **release** в [.gitea/workflows/ci.yaml](../.gitea/workflows/ci.yaml): semantic-release + bake с `VERSION` из релиза.
|
||||
**Publish:** push в `main` после quality gates — workflow [CD](../../.gitea/workflows/cd.yaml) job **publish**: semantic-release + зеркало base-образов + bake с `VERSION` из релиза.
|
||||
|
||||
Локально BuildKit также кэширует `/go/pkg/mod` и `~/.cache/go-build` через `RUN --mount=type=cache`.
|
||||
Bake читает переменные из **окружения** (`REGISTRY`, `IMAGE_TAG`, `CACHE_REF_*`, `BASE_*`). Скрипт [write-bake-override.sh](write-bake-override.sh) — опциональный helper для локальной отладки.
|
||||
|
||||
| Было | Стало |
|
||||
|------|--------|
|
||||
| 8× `go mod download` + 8× `go build` (разные BIN) | 1× download + 1× компиляция всех `cmd/*` |
|
||||
| 2× сборка BIRD из исходников (api, all) | 1× stage `birdc`, копируется в runtime |
|
||||
| 2× `npm ci` (web, web-all) | 1× `web-deps` + 1× `web-build` + два nginx-образа (общий артефакт) |
|
||||
| 8 runner'ов с checkout/login | 1 job `docker-go`, 1 job `docker-web` |
|
||||
BuildKit кэширует `/go/pkg/mod`, `~/.cache/go-build` и pnpm store через `RUN --mount=type=cache`. На CI mounts живут, пока named builder `evobgp` не удаляют (`cleanup: false`).
|
||||
|
||||
Кэш registry (переменные bake):
|
||||
### Слои и runtime
|
||||
|
||||
- `git.shx.one/<owner>/evobgp-buildcache:go-buildcache` — **запись** только из target `go-build-all`
|
||||
- `git.shx.one/<owner>/evobgp-buildcache:web-buildcache` — **запись** только из target `web-build`
|
||||
| Образ | Runtime base | Заметка |
|
||||
|-------|----------------|---------|
|
||||
| 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` |
|
||||
|
||||
Остальные bake-target’ы только `cache-from` (чтение). Параллельный `cache-to` в один ref ломает manifest в registry (`content descriptor … not found`).
|
||||
Сборка Go: `golang:1.24-alpine`. Context режется корневым [.dockerignore](../../.dockerignore).
|
||||
|
||||
Локально BuildKit также кэширует `/go/pkg/mod` и `~/.cache/go-build` через `RUN --mount=type=cache`.
|
||||
### Кэш registry
|
||||
|
||||
Если CI падает на «not found» после смены схемы кэша — один раз удалите теги `evobgp-buildcache:go-buildcache` и `:web-buildcache` в registry и пересоберите.
|
||||
- `git.shx.one/<owner>/evobgp-buildcache:go-buildcache` — **запись** только из `go-build-all`
|
||||
- `git.shx.one/<owner>/evobgp-buildcache:web-buildcache` — **запись** только из `web-build`
|
||||
- `git.shx.one/<owner>/evobgp-buildcache:birdc-buildcache` — **запись** только из `go-birdc`
|
||||
- `git.shx.one/<owner>/evobgp-buildcache:base-*` — зеркало FROM с **Docker Hub** (`docker.io/library/…`; distroless — `gcr.io`). Только `linux/amd64`; skip если тег уже в Gitea. Неуспешный copy не валит CD — bake берёт Docker Hub FROM для этой базы.
|
||||
|
||||
`pull = false` в bake: не перекачивать FROM, если слой уже в builder. Не запускайте `docker system prune -a` на runner.
|
||||
|
||||
Параллельный `cache-to` в один ref ломает manifest (`content descriptor … not found`).
|
||||
|
||||
Если CI падает на «not found» после смены схемы кэша — один раз удалите теги `evobgp-buildcache:*` в registry и пересоберите.
|
||||
|
||||
## Локальная сборка
|
||||
|
||||
@@ -37,20 +46,24 @@ export IMAGE_TAG=latest
|
||||
export SHORT_SHA=$(git rev-parse --short HEAD)
|
||||
export VERSION=dev
|
||||
export BUILD_TIME=
|
||||
sh write-bake-override.sh
|
||||
docker buildx bake --allow=fs.read=../.. -f docker-bake.hcl -f docker-bake.override.hcl go-images
|
||||
docker buildx bake --allow=fs.read=../.. -f docker-bake.hcl -f docker-bake.override.hcl web-images
|
||||
docker buildx bake --allow=fs.read=../.. -f docker-bake.hcl go-images
|
||||
docker buildx bake --allow=fs.read=../.. -f docker-bake.hcl web-images
|
||||
```
|
||||
|
||||
В `docker-bake.hcl`: `context = "../.."` (корень репо), `dockerfile = "deploy/docker/…"` (путь от корня репо). **CI:** `write-bake-override.sh` + `--allow=fs.read=$GITHUB_WORKSPACE` в `.gitea/workflows/ci.yaml`.
|
||||
Проверка манифеста без сборки:
|
||||
|
||||
Один образ (legacy):
|
||||
```bash
|
||||
docker buildx bake --allow=fs.read=../.. -f docker-bake.hcl --print default
|
||||
```
|
||||
|
||||
В `docker-bake.hcl`: `context = "../.."` резолвится **от cwd** (каталог `deploy/docker/`). `dockerfile` — путь от этого context (корень репо). Из корня репо не вызывать bake с `-f deploy/docker/docker-bake.hcl`: получится `lstat ../../deploy`. Можно задать `BUILDX_BAKE_FILE_RELATIVE_PATHS=1`.
|
||||
|
||||
Один образ (legacy, target `build-all`):
|
||||
|
||||
```bash
|
||||
docker build -f deploy/docker/gobinary/Dockerfile \
|
||||
--target build-all \
|
||||
--build-arg BIN=evobgp-api \
|
||||
-t evobgp-api:local .
|
||||
-t evobgp-build-all:local .
|
||||
```
|
||||
|
||||
Runtime-образы в bake ожидают stage `build-all` (через bake contexts), не отдельный `--build-arg BIN` на старый target `build`.
|
||||
Runtime-образы в bake ожидают stage `build-all` (через bake contexts).
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
# BIRD из репозитория Ubuntu (Noble 24.04 LTS) — актуальнее пакета Debian bookworm.
|
||||
# Сборка из корня репозитория: docker build -f deploy/docker/bird2/Dockerfile .
|
||||
# Зеркало ECR Public вместо прямого pull с Docker Hub.
|
||||
FROM public.ecr.aws/docker/library/ubuntu:noble
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends bird2 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
# Stage bird2-base общий с evobgp-agent (одинаковые слои на speaker-VPS).
|
||||
ARG BASE_UBUNTU=docker.io/library/ubuntu:noble
|
||||
|
||||
FROM ${BASE_UBUNTU} AS bird2-base
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,target=/var/lib/apt,sharing=locked \
|
||||
rm -f /etc/apt/apt.conf.d/docker-clean \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends bird2 ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
FROM bird2-base AS bird2
|
||||
COPY deploy/bird/bird.conf /etc/bird/bird.conf
|
||||
RUN mkdir -p /etc/bird/bird.d
|
||||
EXPOSE 179
|
||||
|
||||
+110
-27
@@ -1,7 +1,7 @@
|
||||
# Единая сборка образов EvoBGP (buildx bake: -f deploy/docker/docker-bake.hcl).
|
||||
# context = "../.." — корень репозитория (относительно этого файла).
|
||||
# dockerfile — путь от корня репозитория (относительно context).
|
||||
# Переменные: REGISTRY, IMAGE_TAG, CACHE_REF_GO, CACHE_REF_WEB
|
||||
# context = "../.." — корень репо. Bake резолвит context от cwd (не от HCL),
|
||||
# поэтому вызов из deploy/docker/ либо BUILDX_BAKE_FILE_RELATIVE_PATHS=1.
|
||||
# Переменные Bake читаются из окружения (см. docs.docker.com/build/bake/variables/).
|
||||
|
||||
variable "REGISTRY" {
|
||||
default = "git.shx.one/evobgp"
|
||||
@@ -35,14 +35,40 @@ variable "CACHE_REF_WEB" {
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "CACHE_REF_BIRDC" {
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "BASE_GOLANG" {
|
||||
default = "docker.io/library/golang:1.24-alpine"
|
||||
}
|
||||
|
||||
variable "BASE_DEBIAN" {
|
||||
default = "docker.io/library/debian:bookworm-slim"
|
||||
}
|
||||
|
||||
variable "BASE_DISTROLESS" {
|
||||
default = "gcr.io/distroless/static-debian12:nonroot"
|
||||
}
|
||||
|
||||
variable "BASE_NODE" {
|
||||
default = "docker.io/library/node:22-alpine"
|
||||
}
|
||||
|
||||
variable "BASE_NGINX" {
|
||||
default = "docker.io/library/nginx:1.27-alpine"
|
||||
}
|
||||
|
||||
variable "BASE_UBUNTU" {
|
||||
default = "docker.io/library/ubuntu:noble"
|
||||
}
|
||||
|
||||
function "go-cache-from" {
|
||||
params = []
|
||||
result = notequal("", CACHE_REF_GO) ? ["type=registry,ref=${CACHE_REF_GO}"] : []
|
||||
}
|
||||
|
||||
# Экспорт кэша — только go-build-all / web-build (один writer на ref).
|
||||
# Несколько target с cache-to в один ref и mode=max дают гонку в registry
|
||||
# (content descriptor not found при параллельном bake).
|
||||
# Экспорт кэша — один writer на ref (параллельный cache-to в один ref ломает manifest).
|
||||
function "go-cache-to-export" {
|
||||
params = []
|
||||
result = notequal("", CACHE_REF_GO) ? ["type=registry,ref=${CACHE_REF_GO},mode=max"] : []
|
||||
@@ -58,6 +84,16 @@ function "web-cache-to-export" {
|
||||
result = notequal("", CACHE_REF_WEB) ? ["type=registry,ref=${CACHE_REF_WEB},mode=max"] : []
|
||||
}
|
||||
|
||||
function "birdc-cache-from" {
|
||||
params = []
|
||||
result = notequal("", CACHE_REF_BIRDC) ? ["type=registry,ref=${CACHE_REF_BIRDC}"] : []
|
||||
}
|
||||
|
||||
function "birdc-cache-to-export" {
|
||||
params = []
|
||||
result = notequal("", CACHE_REF_BIRDC) ? ["type=registry,ref=${CACHE_REF_BIRDC},mode=max"] : []
|
||||
}
|
||||
|
||||
group "default" {
|
||||
targets = ["go-images", "web-images", "evobgp-bird2"]
|
||||
}
|
||||
@@ -75,26 +111,38 @@ group "go-images" {
|
||||
]
|
||||
}
|
||||
|
||||
# web-deps / web-build — только зависимости (contexts), без tags; при --push в группе не указывать.
|
||||
group "web-images" {
|
||||
targets = ["evobgp-web", "evobgp-web-all"]
|
||||
}
|
||||
|
||||
target "_common" {
|
||||
platforms = ["linux/amd64"]
|
||||
pull = false
|
||||
}
|
||||
|
||||
target "_go-bases" {
|
||||
args = {
|
||||
BASE_GOLANG = BASE_GOLANG
|
||||
BASE_DEBIAN = BASE_DEBIAN
|
||||
BASE_DISTROLESS = BASE_DISTROLESS
|
||||
}
|
||||
}
|
||||
|
||||
# --- Go: go mod download → все cmd/* → birdc (один раз) → runtime-образы ---
|
||||
|
||||
target "go-deps" {
|
||||
inherits = ["_common", "_go-bases"]
|
||||
context = "../.."
|
||||
dockerfile = "deploy/docker/gobinary/Dockerfile"
|
||||
target = "deps"
|
||||
platforms = ["linux/amd64"]
|
||||
cache-from = go-cache-from()
|
||||
}
|
||||
|
||||
target "go-build-all" {
|
||||
inherits = ["_common", "_go-bases"]
|
||||
context = "../.."
|
||||
dockerfile = "deploy/docker/gobinary/Dockerfile"
|
||||
target = "build-all"
|
||||
platforms = ["linux/amd64"]
|
||||
args = {
|
||||
VERSION = VERSION
|
||||
GIT_SHA = notequal("", SHA_FULL) ? SHA_FULL : SHORT_SHA
|
||||
@@ -108,18 +156,19 @@ target "go-build-all" {
|
||||
}
|
||||
|
||||
target "go-birdc" {
|
||||
inherits = ["_common", "_go-bases"]
|
||||
context = "../.."
|
||||
dockerfile = "deploy/docker/gobinary/Dockerfile"
|
||||
target = "birdc"
|
||||
platforms = ["linux/amd64"]
|
||||
cache-from = go-cache-from()
|
||||
cache-from = birdc-cache-from()
|
||||
cache-to = birdc-cache-to-export()
|
||||
}
|
||||
|
||||
target "_go-runtime" {
|
||||
inherits = ["_common", "_go-bases"]
|
||||
context = "../.."
|
||||
dockerfile = "deploy/docker/gobinary/Dockerfile"
|
||||
target = "runtime"
|
||||
platforms = ["linux/amd64"]
|
||||
contexts = {
|
||||
build-all = "target:go-build-all"
|
||||
}
|
||||
@@ -127,15 +176,15 @@ target "_go-runtime" {
|
||||
}
|
||||
|
||||
target "_go-runtime-birdc" {
|
||||
inherits = ["_common", "_go-bases"]
|
||||
context = "../.."
|
||||
dockerfile = "deploy/docker/gobinary/Dockerfile"
|
||||
target = "runtime-birdc"
|
||||
platforms = ["linux/amd64"]
|
||||
contexts = {
|
||||
build-all = "target:go-build-all"
|
||||
birdc = "target:go-birdc"
|
||||
}
|
||||
cache-from = go-cache-from()
|
||||
cache-from = concat(go-cache-from(), birdc-cache-from())
|
||||
}
|
||||
|
||||
function "image-tags" {
|
||||
@@ -187,43 +236,62 @@ 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")
|
||||
}
|
||||
|
||||
target "evobgp-bird2-base" {
|
||||
inherits = ["_common"]
|
||||
context = "../.."
|
||||
dockerfile = "deploy/docker/bird2/Dockerfile"
|
||||
target = "bird2-base"
|
||||
args = {
|
||||
BASE_UBUNTU = BASE_UBUNTU
|
||||
}
|
||||
}
|
||||
|
||||
target "evobgp-agent" {
|
||||
inherits = ["_common"]
|
||||
context = "../.."
|
||||
dockerfile = "deploy/docker/evobgp-agent/Dockerfile"
|
||||
platforms = ["linux/amd64"]
|
||||
contexts = {
|
||||
build-all = "target:go-build-all"
|
||||
build-all = "target:go-build-all"
|
||||
bird2-base = "target:evobgp-bird2-base"
|
||||
}
|
||||
cache-from = go-cache-from()
|
||||
tags = image-tags("evobgp-agent")
|
||||
}
|
||||
|
||||
# --- Web: npm ci (кэш) → build → nginx ---
|
||||
# --- Web ---
|
||||
|
||||
target "web-deps" {
|
||||
inherits = ["_common"]
|
||||
context = "../.."
|
||||
dockerfile = "deploy/docker/evobgp-web/Dockerfile"
|
||||
target = "deps"
|
||||
platforms = ["linux/amd64"]
|
||||
args = {
|
||||
BASE_NODE = BASE_NODE
|
||||
BASE_NGINX = BASE_NGINX
|
||||
}
|
||||
cache-from = web-cache-from()
|
||||
}
|
||||
|
||||
target "web-build" {
|
||||
inherits = ["_common"]
|
||||
context = "../.."
|
||||
dockerfile = "deploy/docker/evobgp-web/Dockerfile"
|
||||
target = "build"
|
||||
platforms = ["linux/amd64"]
|
||||
args = {
|
||||
BASE_NODE = BASE_NODE
|
||||
BASE_NGINX = BASE_NGINX
|
||||
}
|
||||
contexts = {
|
||||
deps = "target:web-deps"
|
||||
}
|
||||
@@ -232,34 +300,49 @@ target "web-build" {
|
||||
}
|
||||
|
||||
target "evobgp-web" {
|
||||
inherits = ["_common"]
|
||||
context = "../.."
|
||||
dockerfile = "deploy/docker/evobgp-web/Dockerfile"
|
||||
target = "web"
|
||||
platforms = ["linux/amd64"]
|
||||
contexts = {
|
||||
web-artifacts = "target:web-build"
|
||||
}
|
||||
args = { EVOBGP_UPSTREAM = "evobgp-api" }
|
||||
args = {
|
||||
EVOBGP_UPSTREAM = "evobgp-api"
|
||||
BASE_NODE = BASE_NODE
|
||||
BASE_NGINX = BASE_NGINX
|
||||
}
|
||||
cache-from = web-cache-from()
|
||||
tags = image-tags("evobgp-web")
|
||||
}
|
||||
|
||||
target "evobgp-web-all" {
|
||||
inherits = ["_common"]
|
||||
context = "../.."
|
||||
dockerfile = "deploy/docker/evobgp-web/Dockerfile"
|
||||
target = "web"
|
||||
platforms = ["linux/amd64"]
|
||||
contexts = {
|
||||
web-artifacts = "target:web-build"
|
||||
}
|
||||
args = { EVOBGP_UPSTREAM = "evobgp-all" }
|
||||
args = {
|
||||
EVOBGP_UPSTREAM = "evobgp-all"
|
||||
BASE_NODE = BASE_NODE
|
||||
BASE_NGINX = BASE_NGINX
|
||||
}
|
||||
cache-from = web-cache-from()
|
||||
tags = image-tags("evobgp-web-all")
|
||||
}
|
||||
|
||||
target "evobgp-bird2" {
|
||||
inherits = ["_common"]
|
||||
context = "../.."
|
||||
dockerfile = "deploy/docker/bird2/Dockerfile"
|
||||
platforms = ["linux/amd64"]
|
||||
tags = image-tags("evobgp-bird2")
|
||||
target = "bird2"
|
||||
args = {
|
||||
BASE_UBUNTU = BASE_UBUNTU
|
||||
}
|
||||
contexts = {
|
||||
bird2-base = "target:evobgp-bird2-base"
|
||||
}
|
||||
tags = image-tags("evobgp-bird2")
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
# Агент: бинарь из общего build-all (docker-bake.hcl → contexts.build-all), bird2 из apt.
|
||||
FROM public.ecr.aws/docker/library/ubuntu:noble
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends bird2 ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
# Агент: бинарь из bake context build-all, runtime = тот же bird2-base, что evobgp-bird2.
|
||||
FROM bird2-base
|
||||
ARG BIN=evobgp-agent
|
||||
COPY --from=build-all /out/${BIN} /usr/local/bin/evobgp-agent
|
||||
WORKDIR /etc/bird
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
# React + Vite статическая панель EvoBGP + nginx.
|
||||
# Финальный stage `web` ожидает bake-контекст web-artifacts (= target:web-build).
|
||||
# Сборка ведётся из корня репозитория (context = "../.." в docker-bake.hcl).
|
||||
ARG BASE_NODE=docker.io/library/node:22-alpine
|
||||
ARG BASE_NGINX=docker.io/library/nginx:1.27-alpine
|
||||
|
||||
FROM public.ecr.aws/docker/library/node:22-alpine AS deps
|
||||
FROM ${BASE_NODE} AS deps
|
||||
WORKDIR /repo
|
||||
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
|
||||
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
||||
@@ -16,11 +17,13 @@ FROM deps AS build
|
||||
COPY tsconfig.base.json ./
|
||||
COPY apps/web/ ./apps/web/
|
||||
COPY packages/ui/ ./packages/ui/
|
||||
RUN pnpm --filter @evobgp/web run build
|
||||
RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \
|
||||
pnpm --filter @evobgp/web run build
|
||||
|
||||
FROM public.ecr.aws/docker/library/nginx:1.27-alpine AS web
|
||||
FROM ${BASE_NGINX} AS web
|
||||
ARG EVOBGP_UPSTREAM=evobgp-api
|
||||
COPY deploy/docker/evobgp-web/nginx.conf /tmp/nginx-default.conf
|
||||
RUN sed -e "s/evobgp-api/${EVOBGP_UPSTREAM}/g" /tmp/nginx-default.conf > /etc/nginx/conf.d/default.conf \
|
||||
&& rm -f /tmp/nginx-default.conf
|
||||
&& rm -f /tmp/nginx-default.conf \
|
||||
&& sed -i 's/^[[:space:]]*worker_processes[[:space:]]*auto;/worker_processes 1;/' /etc/nginx/nginx.conf
|
||||
COPY --from=web-artifacts /repo/apps/web/dist /usr/share/nginx/html
|
||||
|
||||
@@ -4,6 +4,7 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
gzip on;
|
||||
gzip_types text/css application/javascript application/json image/svg+xml;
|
||||
gzip_min_length 256;
|
||||
|
||||
# Docker embedded DNS: без resolver nginx кэширует IP upstream при старте —
|
||||
# после recreate evobgp-all остаётся 502 (connection refused на старый IP).
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
# Универсальная сборка бинарей cmd/* (ARG BIN) или всех сразу (target build-all).
|
||||
# INSTALL_BIRDC=1 — birdc из stage birdc (собирается один раз, переиспользуется api/all).
|
||||
# Воркеры (runtime): distroless static. api/all/deploy/node (runtime-birdc): debian-slim + bird + birdc.
|
||||
# CI: docker buildx bake -f deploy/docker/docker-bake.hcl
|
||||
FROM public.ecr.aws/docker/library/golang:1.24-bookworm AS deps
|
||||
ARG BASE_GOLANG=docker.io/library/golang:1.24-alpine
|
||||
ARG BASE_DEBIAN=docker.io/library/debian:bookworm-slim
|
||||
ARG BASE_DISTROLESS=gcr.io/distroless/static-debian12:nonroot
|
||||
|
||||
FROM ${BASE_GOLANG} AS deps
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
|
||||
go mod download
|
||||
|
||||
FROM deps AS build-all
|
||||
COPY . .
|
||||
ARG VERSION=dev
|
||||
ARG GIT_SHA=unknown
|
||||
ARG BUILD_TIME=
|
||||
RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
|
||||
RUN --mount=type=bind,target=. \
|
||||
--mount=type=cache,target=/go/pkg/mod,sharing=locked \
|
||||
--mount=type=cache,target=/root/.cache/go-build,sharing=locked \
|
||||
set -eux; \
|
||||
mkdir -p /out; \
|
||||
@@ -29,12 +33,12 @@ RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
|
||||
|
||||
# Один бинарь (локальная сборка); в CI — build-all + runtime.
|
||||
FROM deps AS build
|
||||
COPY . .
|
||||
ARG BIN=evobgp-api
|
||||
ARG VERSION=dev
|
||||
ARG GIT_SHA=unknown
|
||||
ARG BUILD_TIME=
|
||||
RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
|
||||
RUN --mount=type=bind,target=. \
|
||||
--mount=type=cache,target=/go/pkg/mod,sharing=locked \
|
||||
--mount=type=cache,target=/root/.cache/go-build,sharing=locked \
|
||||
CGO_ENABLED=0 go build -trimpath \
|
||||
-ldflags="-s -w \
|
||||
@@ -43,29 +47,35 @@ RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
|
||||
-X evobgp/internal/version.BuildTime=${BUILD_TIME}" \
|
||||
-o /out/evobgp "./cmd/${BIN}"
|
||||
|
||||
FROM public.ecr.aws/docker/library/debian:bookworm-slim AS birdc
|
||||
FROM ${BASE_DEBIAN} AS birdc
|
||||
ARG BIRD_VERSION=2.14
|
||||
COPY deploy/docker/bird/bird-from-source.sh /tmp/bird-from-source.sh
|
||||
RUN chmod +x /tmp/bird-from-source.sh \
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,target=/var/lib/apt,sharing=locked \
|
||||
rm -f /etc/apt/apt.conf.d/docker-clean \
|
||||
&& chmod +x /tmp/bird-from-source.sh \
|
||||
&& BIRD_VERSION="${BIRD_VERSION}" /tmp/bird-from-source.sh \
|
||||
&& rm -f /tmp/bird-from-source.sh
|
||||
|
||||
FROM public.ecr.aws/docker/library/debian:bookworm-slim AS runtime-base
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
FROM runtime-base AS runtime
|
||||
# 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
|
||||
COPY scripts/firewall /opt/evobgp/scripts/firewall
|
||||
ENV EVOBGP_FIREWALL_SCRIPTS=/opt/evobgp/scripts/firewall
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/usr/local/bin/evobgp"]
|
||||
|
||||
FROM runtime AS runtime-birdc
|
||||
# birdc: динамическая линковка readline + ncurses (debian bookworm).
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends libreadline8 libncurses6 \
|
||||
# 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 \
|
||||
rm -f /etc/apt/apt.conf.d/docker-clean \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates libreadline8 libncurses6 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=birdc /usr/local/sbin/bird /usr/local/sbin/birdc /usr/local/sbin/
|
||||
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"]
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env sh
|
||||
# Copy-by-digest зеркало базовых образов в Gitea Container Registry (без rebuild).
|
||||
# Требует: docker login в REGISTRY host, docker buildx.
|
||||
# REGISTRY=git.shx.one/<owner> (без суффикса /evobgp-buildcache)
|
||||
#
|
||||
# Источник library-образов — Docker Hub (docker.io), не public.ecr.aws (429 Too Many Requests).
|
||||
# Если тег уже есть в Gitea — skip (без повторного pull).
|
||||
# Копируем только linux/amd64 — bake platforms совпадает, multi-arch индекс не нужен.
|
||||
# Шаг CD не должен падать: неуспешный copy оставляет bake на Docker Hub FROM для этой базы.
|
||||
set -eu
|
||||
REGISTRY="${REGISTRY:?REGISTRY required (git.shx.one/<owner>)}"
|
||||
CACHE_REPO="${REGISTRY}/evobgp-buildcache"
|
||||
ENV_FILE="${MIRROR_ENV_FILE:-}"
|
||||
PLATFORM="${MIRROR_PLATFORM:-linux/amd64}"
|
||||
RETRIES="${MIRROR_RETRIES:-4}"
|
||||
|
||||
tmp="$(mktemp)"
|
||||
cleanup() { rm -f "$tmp"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
dest_exists() {
|
||||
docker buildx imagetools inspect "$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
copy_retry() {
|
||||
src="$1"
|
||||
dest="$2"
|
||||
n=0
|
||||
while [ "$n" -lt "$RETRIES" ]; do
|
||||
n=$((n + 1))
|
||||
if docker buildx imagetools create --platform "$PLATFORM" --tag "$dest" "$src"; then
|
||||
return 0
|
||||
fi
|
||||
delay=$((n * 25))
|
||||
echo "mirror retry ${n}/${RETRIES}, sleep ${delay}s: ${dest}"
|
||||
sleep "$delay"
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
mirror() {
|
||||
src="$1"
|
||||
tag="$2"
|
||||
var="$3"
|
||||
dest="${CACHE_REPO}:${tag}"
|
||||
if dest_exists "$dest"; then
|
||||
echo "skip (already in registry): ${dest}"
|
||||
printf '%s=%s\n' "$var" "$dest" >>"$tmp"
|
||||
return 0
|
||||
fi
|
||||
echo "mirror ${src} -> ${dest} (${PLATFORM})"
|
||||
if copy_retry "$src" "$dest"; then
|
||||
printf '%s=%s\n' "$var" "$dest" >>"$tmp"
|
||||
return 0
|
||||
fi
|
||||
echo "warn: ${dest} not mirrored — bake uses Docker Hub FROM for ${var}"
|
||||
return 0
|
||||
}
|
||||
|
||||
mirror "docker.io/library/golang:1.24-alpine" "base-golang-1.24-alpine" BASE_GOLANG
|
||||
sleep 8
|
||||
mirror "docker.io/library/debian:bookworm-slim" "base-debian-bookworm-slim" BASE_DEBIAN
|
||||
sleep 8
|
||||
mirror "docker.io/library/node:22-alpine" "base-node-22-alpine" BASE_NODE
|
||||
sleep 8
|
||||
mirror "docker.io/library/nginx:1.27-alpine" "base-nginx-1.27-alpine" BASE_NGINX
|
||||
sleep 8
|
||||
mirror "docker.io/library/ubuntu:noble" "base-ubuntu-noble" BASE_UBUNTU
|
||||
sleep 8
|
||||
mirror "gcr.io/distroless/static-debian12:nonroot" "base-distroless-static-debian12-nonroot" BASE_DISTROLESS
|
||||
|
||||
echo "----- mirrored BASE_* -----"
|
||||
cat "$tmp"
|
||||
if [ -n "$ENV_FILE" ]; then
|
||||
env_dir="$(dirname "$ENV_FILE")"
|
||||
if [ -d "$env_dir" ]; then
|
||||
cp "$tmp" "$ENV_FILE"
|
||||
echo "wrote ${ENV_FILE}"
|
||||
else
|
||||
echo "warn: MIRROR_ENV_FILE dir missing (${env_dir}) — skip write"
|
||||
fi
|
||||
fi
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env sh
|
||||
# Writes docker-bake.override.hcl for CI (buildx without --var support).
|
||||
# Optional helper for local bake when env-based Bake variables are inconvenient.
|
||||
# CI задаёт те же имена переменных в окружении и вызывает bake без этого файла.
|
||||
set -eu
|
||||
OUT="${1:-docker-bake.override.hcl}"
|
||||
REGISTRY="${REGISTRY:?REGISTRY required}"
|
||||
@@ -10,30 +11,29 @@ VERSION="${VERSION:-dev}"
|
||||
BUILD_TIME="${BUILD_TIME:-}"
|
||||
CACHE_REF_GO="${CACHE_REF_GO:-}"
|
||||
CACHE_REF_WEB="${CACHE_REF_WEB:-}"
|
||||
CACHE_REF_BIRDC="${CACHE_REF_BIRDC:-}"
|
||||
BASE_GOLANG="${BASE_GOLANG:-}"
|
||||
BASE_DEBIAN="${BASE_DEBIAN:-}"
|
||||
BASE_DISTROLESS="${BASE_DISTROLESS:-}"
|
||||
BASE_NODE="${BASE_NODE:-}"
|
||||
BASE_NGINX="${BASE_NGINX:-}"
|
||||
BASE_UBUNTU="${BASE_UBUNTU:-}"
|
||||
cat >"$OUT" <<EOF
|
||||
# Generated by deploy/docker/write-bake-override.sh — do not commit.
|
||||
variable "REGISTRY" {
|
||||
default = "${REGISTRY}"
|
||||
}
|
||||
variable "IMAGE_TAG" {
|
||||
default = "${IMAGE_TAG}"
|
||||
}
|
||||
variable "SHORT_SHA" {
|
||||
default = "${SHORT_SHA}"
|
||||
}
|
||||
variable "SHA_FULL" {
|
||||
default = "${SHA_FULL}"
|
||||
}
|
||||
variable "VERSION" {
|
||||
default = "${VERSION}"
|
||||
}
|
||||
variable "BUILD_TIME" {
|
||||
default = "${BUILD_TIME}"
|
||||
}
|
||||
variable "CACHE_REF_GO" {
|
||||
default = "${CACHE_REF_GO}"
|
||||
}
|
||||
variable "CACHE_REF_WEB" {
|
||||
default = "${CACHE_REF_WEB}"
|
||||
}
|
||||
variable "REGISTRY" { default = "${REGISTRY}" }
|
||||
variable "IMAGE_TAG" { default = "${IMAGE_TAG}" }
|
||||
variable "SHORT_SHA" { default = "${SHORT_SHA}" }
|
||||
variable "SHA_FULL" { default = "${SHA_FULL}" }
|
||||
variable "VERSION" { default = "${VERSION}" }
|
||||
variable "BUILD_TIME" { default = "${BUILD_TIME}" }
|
||||
variable "CACHE_REF_GO" { default = "${CACHE_REF_GO}" }
|
||||
variable "CACHE_REF_WEB" { default = "${CACHE_REF_WEB}" }
|
||||
variable "CACHE_REF_BIRDC" { default = "${CACHE_REF_BIRDC}" }
|
||||
EOF
|
||||
# BASE_* — только если заданы (иначе bake берёт default из docker-bake.hcl).
|
||||
if [ -n "$BASE_GOLANG" ]; then printf 'variable "BASE_GOLANG" { default = "%s" }\n' "$BASE_GOLANG" >>"$OUT"; fi
|
||||
if [ -n "$BASE_DEBIAN" ]; then printf 'variable "BASE_DEBIAN" { default = "%s" }\n' "$BASE_DEBIAN" >>"$OUT"; fi
|
||||
if [ -n "$BASE_DISTROLESS" ]; then printf 'variable "BASE_DISTROLESS" { default = "%s" }\n' "$BASE_DISTROLESS" >>"$OUT"; fi
|
||||
if [ -n "$BASE_NODE" ]; then printf 'variable "BASE_NODE" { default = "%s" }\n' "$BASE_NODE" >>"$OUT"; fi
|
||||
if [ -n "$BASE_NGINX" ]; then printf 'variable "BASE_NGINX" { default = "%s" }\n' "$BASE_NGINX" >>"$OUT"; fi
|
||||
if [ -n "$BASE_UBUNTU" ]; then printf 'variable "BASE_UBUNTU" { default = "%s" }\n' "$BASE_UBUNTU" >>"$OUT"; fi
|
||||
|
||||
+52
-6
@@ -1801,11 +1801,55 @@ components:
|
||||
type: string
|
||||
description: URL agent или https://AGENT_DOMAIN
|
||||
meta_json:
|
||||
oneOf:
|
||||
- type: string
|
||||
- type: object
|
||||
description: >
|
||||
JSON-объект (строка или object). Ключи node_ipv4, bird_bgp_source_ipv4
|
||||
(default = node_ipv4), agent_domain, agent_secret (генерируется при создании если пуст).
|
||||
letsencrypt_email:
|
||||
type: string
|
||||
description: Email ACME для Traefik на ноде. Только для генерации install.docker_commands, не сохраняется.
|
||||
cf_dns_api_token:
|
||||
type: string
|
||||
description: Cloudflare DNS API token (Zone:DNS:Edit) для LE DNS-01. Только для install-сниппета, не сохраняется.
|
||||
panel_ip_whitelist:
|
||||
type: string
|
||||
description: CIDR/IP панели для Traefik ipallowlist. Только для install-сниппета, не сохраняется.
|
||||
control_plane_url:
|
||||
type: string
|
||||
format: uri
|
||||
description: Публичный HTTPS URL панели (EVOBGP_CONTROL_PLANE_URL на реплике). Если пуст — из Origin / X-Forwarded-Host.
|
||||
additionalProperties: true
|
||||
|
||||
SpeakerInstall:
|
||||
type: object
|
||||
description: Одноразовый пакет установки реплики (только POST /v1/speakers 201).
|
||||
properties:
|
||||
docker_commands:
|
||||
type: string
|
||||
description: >
|
||||
JSON-объект. Ключи node_ipv4, bird_bgp_source_ipv4 (default = node_ipv4),
|
||||
agent_domain, agent_secret (генерируется при создании если пуст).
|
||||
additionalProperties: true
|
||||
Bash: sysctl, heredoc docker-compose.yaml (bird2 + agent + Traefik DNS-01) и docker compose up -d.
|
||||
compose_yaml:
|
||||
type: string
|
||||
description: Тело docker-compose.yaml без heredoc (превью).
|
||||
|
||||
BgpSpeakerCreated:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/BgpSpeaker"
|
||||
- type: object
|
||||
properties:
|
||||
agent_secret:
|
||||
type: string
|
||||
description: Bearer для Panel→Node (EVOBGP_AGENT_SECRET). Только в 201.
|
||||
node_token:
|
||||
type: string
|
||||
description: API-ключ role=node (EVOBGP_NODE_TOKEN). Только в 201.
|
||||
bundle_pubkey_base64:
|
||||
type: string
|
||||
description: Ed25519 pubkey для verify-bundle на ноде.
|
||||
install:
|
||||
$ref: "#/components/schemas/SpeakerInstall"
|
||||
|
||||
BgpSpeakerPatch:
|
||||
type: object
|
||||
@@ -3360,7 +3404,9 @@ paths:
|
||||
post:
|
||||
tags: [Speakers]
|
||||
summary: Зарегистрировать спикер
|
||||
description: Реплика, canary и т.д.
|
||||
description: >
|
||||
Реплика или master. Для replica 201 содержит agent_secret, node_token и
|
||||
install.docker_commands (bird2 + agent + Traefik LE DNS-01) — один раз.
|
||||
operationId: createSpeaker
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/TenantId"
|
||||
@@ -3373,11 +3419,11 @@ paths:
|
||||
$ref: "#/components/schemas/BgpSpeakerCreate"
|
||||
responses:
|
||||
"201":
|
||||
description: Ресурс создан.
|
||||
description: Ресурс создан. Для replica — одноразовый install-сниппет.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BgpSpeaker"
|
||||
$ref: "#/components/schemas/BgpSpeakerCreated"
|
||||
default:
|
||||
$ref: "#/components/responses/DefaultProblem"
|
||||
|
||||
|
||||
+2
-2
@@ -11,7 +11,7 @@
|
||||
|
||||
## Готовые образы без сборки (Container Registry Gitea)
|
||||
|
||||
После успешного CI (push в `main` или `master`) образы публикуются в **Container Registry** вашего Gitea. В workflow зафиксирован хост реестра **`git.shx.one`**; имя владельца в пути образа — **в нижнем регистре**, как у `github.repository_owner` в CI (например, пользователь `Denozord` → префикс `denozord`).
|
||||
После успешного **CD** (push в `main` или `master`, job **publish**) образы публикуются в **Container Registry** вашего Gitea. В workflow зафиксирован хост реестра **`git.shx.one`**; имя владельца в пути образа — **в нижнем регистре**, как у `github.repository_owner` в CI (например, пользователь `Denozord` → префикс `denozord`).
|
||||
|
||||
### Шаблон имени и теги
|
||||
|
||||
@@ -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` |
|
||||
|
||||
+13
-12
@@ -23,21 +23,22 @@ EvoBGP использует [Conventional Commits](https://www.conventionalcommi
|
||||
|
||||
Подробные правила сообщений коммитов: [.cursor/rules/conventional-commits.mdc](../.cursor/rules/conventional-commits.mdc).
|
||||
|
||||
## CI-пайплайн (один push в main)
|
||||
## CI-пайплайн (push в main)
|
||||
|
||||
```text
|
||||
push/merge в main
|
||||
→ CI: openapi, web, go, bird2 (параллельно)
|
||||
→ job release (в том же workflow, после quality gates):
|
||||
→ workflow CD: quality (openapi, web, go, bird2)
|
||||
→ job publish:
|
||||
→ semantic-release: git tag vX.Y.Z на текущий commit (без доп. commit)
|
||||
→ Gitea Release + CHANGELOG.md как attachment
|
||||
→ docker buildx bake с VERSION=X.Y.Z
|
||||
→ зеркало base-образов в evobgp-buildcache:base-*
|
||||
→ docker buildx bake с VERSION=X.Y.Z (pull=false, named builder evobgp)
|
||||
→ образы: latest, vX.Y.Z, X.Y.Z, sha-*, короткий SHA
|
||||
```
|
||||
|
||||
Pull request: только quality gates + commitlint; релиз и образы **не** публикуются.
|
||||
Pull request: workflow **CI** — quality gates + commitlint; релиз и образы **не** публикуются.
|
||||
|
||||
Workflow: [.gitea/workflows/ci.yaml](../.gitea/workflows/ci.yaml) (job **release**).
|
||||
Workflows: [.gitea/workflows/ci.yaml](../.gitea/workflows/ci.yaml), [.gitea/workflows/cd.yaml](../.gitea/workflows/cd.yaml), reusable [.gitea/workflows/quality.yaml](../.gitea/workflows/quality.yaml).
|
||||
|
||||
Конфиг semantic-release: [.releaserc.json](../.releaserc.json) — без `@semantic-release/git` (CHANGELOG не коммитится в репозиторий).
|
||||
|
||||
@@ -51,7 +52,7 @@ Workflow: [.gitea/workflows/ci.yaml](../.gitea/workflows/ci.yaml) (job **release
|
||||
| releases | Gitea Release + notes |
|
||||
| packages (Container Registry) | push образов |
|
||||
|
||||
Fallback: **`gitea.token`** (нужны права на releases и packages).
|
||||
Fallback для **git tag**: `github.token`, если PAT недоступен. Push образов в Container Registry — **только `ACTIONS_PAT`** (у job token Gitea нет права packages).
|
||||
|
||||
## Источник правды для версии в runtime
|
||||
|
||||
@@ -75,15 +76,15 @@ Web UI показывает версию из API (footer sidebar, страни
|
||||
|
||||
Правило: **один scope** из таблицы в [.cursor/rules/conventional-commits.mdc](../.cursor/rules/conventional-commits.mdc) (`web`, `httpapi`, `api`, …).
|
||||
|
||||
На push в `main` job **release** запускает `scripts/commit/verify-release-commits.mjs` — в логе будут предупреждения о непарсящихся коммитах.
|
||||
На push в `main` job **publish** запускает `scripts/commit/verify-release-commits.mjs` — в логе будут предупреждения о непарсящихся коммитах.
|
||||
|
||||
Если релиз «не создался», а CI зелёный: смотрите лог release — часто `No releasable commits`. Исправление: новый коммит с корректным заголовком (например `refactor(web): …`).
|
||||
|
||||
## Перезапуск упавшего job release
|
||||
## Перезапуск упавшего job publish
|
||||
|
||||
semantic-release пишет `.release-version` только в `successCmd` при **новом** релизе. Если тег `vX.Y.Z` уже создан, а `docker buildx bake` упал, повторный run того же SHA делает semantic-release no-op (файла нет). Job **release** тогда берёт версию из git-тега на `HEAD` и публикует образы.
|
||||
semantic-release пишет `.release-version` только в `successCmd` при **новом** релизе. Если тег `vX.Y.Z` уже создан, а `docker buildx bake` упал, повторный run того же SHA делает semantic-release no-op (файла нет). Job **publish** тогда берёт версию из git-тега на `HEAD` и публикует образы.
|
||||
|
||||
Перезапускать нужно **весь job release**, не отдельный шаг bake: checkout + semantic-release + detect + bake идут подряд.
|
||||
Перезапускать нужно **весь job publish**, не отдельный шаг bake: checkout + semantic-release + detect + bake идут подряд.
|
||||
|
||||
## CHANGELOG
|
||||
|
||||
@@ -91,7 +92,7 @@ Release notes — в Gitea Release; файл `CHANGELOG.md` генерирует
|
||||
|
||||
## Проверка после релиза
|
||||
|
||||
1. Один run workflow **CI** на push в main: job **release** зелёный.
|
||||
1. Один run workflow **CD** на push в main: job **publish** зелёный.
|
||||
2. Gitea: тег `vX.Y.Z` на том же commit, что и merge; Release с notes.
|
||||
3. Container Registry: `evobgp-api:vX.Y.Z`, `evobgp-api:X.Y.Z`, `evobgp-api:latest`.
|
||||
4. `curl http://localhost:8080/version` → `"version":"X.Y.Z"`.
|
||||
|
||||
+67
-40
@@ -8,52 +8,64 @@ Runbook для реплик **bird2 + evobgp-agent** на отдельных VPS
|
||||
|-----------|--------|
|
||||
| Panel → Node:PORT | CP POST `https://AGENT_DOMAIN/v1/agent/sync` |
|
||||
| SECRET_KEY | `agent_secret` (Bearer) |
|
||||
| Copy compose | Web UI → карточка спикера |
|
||||
| Copy compose | Web UI → после создания реплики: docker-команды (bird2 + agent + Traefik) |
|
||||
| Push Xray JSON | Wake-up → pull signed bundle → verify Ed25519 → apply |
|
||||
|
||||
Подробнее: [architecture.md](architecture.md).
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
1. **CP (microvps-full):** зафиксируйте `EVOBGP_BUNDLE_SEED_HEX` (32 байта hex) — стабильный ключ подписи бандлов.
|
||||
2. **Web UI → Сеть → Спикеры:** создайте спикер `role=replica`, укажите **Agent domain**, **IP ноды**, **BGP source** (по умолчанию = IP ноды).
|
||||
3. Сохраните **`agent_secret`** (показывается один раз) и скопируйте **docker-compose** из UI.
|
||||
4. Выдайте **node API-ключ** ([access.md](access.md)) для `EVOBGP_NODE_TOKEN`.
|
||||
5. `GET /v1/bundle/signing-public-key` → `EVOBGP_BUNDLE_PUBKEY_BASE64` на реплике.
|
||||
6. На VPS реплики:
|
||||
```bash
|
||||
cd deploy/compose
|
||||
cp .env.remote-speaker.example .env.remote-speaker
|
||||
cp .env.remote-speaker-tls.example .env.remote-speaker-tls
|
||||
# заполните переменные из UI
|
||||
docker compose -f docker-compose.remote-speaker.yaml \
|
||||
--env-file .env.remote-speaker --env-file .env.remote-speaker-tls \
|
||||
--profile production up -d
|
||||
```
|
||||
7. **CP:** `EVOBGP_NODE_DISPATCH_ENABLED=1` — Panel шлёт wake-up после publish.
|
||||
8. Cloudflare: `AGENT_DOMAIN` → IP VPS, **DNS only** (как Web UI в [quickstart.md](quickstart.md)).
|
||||
1. **CP (microvps-full):** зафиксируйте `EVOBGP_BUNDLE_SEED_HEX` (32 байта hex) — стабильный ключ подписи бандлов. `EVOBGP_NODE_DISPATCH_ENABLED=1`.
|
||||
2. Cloudflare: A/AAAA `AGENT_DOMAIN` → публичный IP VPS реплики, режим **DNS only** (серый облачко), как Web UI в [quickstart.md](quickstart.md).
|
||||
3. **Web UI → Сеть → Спикеры:** создайте спикер `role=replica`. Укажите **домен агента**, **IP ноды**, **BGP source** (по умолчанию = IP ноды), **email Let's Encrypt**, **Cloudflare DNS API token** (`Zone:DNS:Edit`), **IP панели** (CIDR whitelist).
|
||||
4. В диалоге «Установка на ноду» скопируйте **docker-команды** (секреты `agent_secret` и `node_token` показываются **один раз**). Репозиторий EvoBGP на ноде не нужен: команда пишет `/opt/evobgp-speaker/docker-compose.yaml` (bird2 + agent + Traefik DNS-01) и делает `docker compose up -d`.
|
||||
5. Если образы из приватного реестра — на VPS заранее `docker login git.shx.one`.
|
||||
6. Не делайте `docker compose down -v` на реплике без бэкапа тома `evobgp_speaker_traefik_letsencrypt` (`acme.json`).
|
||||
|
||||
## Compose-профили
|
||||
Эталонный compose в репозитории (lab / ручной запуск): [docker-compose.remote-speaker.yaml](../deploy/compose/docker-compose.remote-speaker.yaml). Prod-установка с панели — paste из UI.
|
||||
|
||||
| Profile | Состав |
|
||||
|---------|--------|
|
||||
| `production` | bird2 (host) + agent + Traefik LE |
|
||||
| `plain` | bird2 + agent на хосте без Traefik (только lab) |
|
||||
| `fallback` | + `sync-bundle` polling (`scripts/sync-bundle.sh`) |
|
||||
## HTTPS на ноде (DNS-01)
|
||||
|
||||
Файлы: [docker-compose.remote-speaker.yaml](../deploy/compose/docker-compose.remote-speaker.yaml).
|
||||
Сертификат **не** выписывает Control Plane и **не** Cloudflare Origin CA. Его выпускает **Traefik на самой реплике** (`evobgp-edge`), resolver `letsencrypt`, **ACME DNS-01** через Cloudflare.
|
||||
|
||||
## Firewall
|
||||
| Кто | Что делает |
|
||||
|-----|------------|
|
||||
| Оператор | DNS only: `AGENT_DOMAIN` → IP VPS |
|
||||
| Traefik на **ноде** | `dnschallenge=true`, `provider=cloudflare` |
|
||||
| `CF_DNS_API_TOKEN` | В env **реплики** (вшит в команду из UI). Traefik создаёт TXT `_acme-challenge.<AGENT_DOMAIN>` |
|
||||
| Let's Encrypt | Проверяет TXT, отдаёт сертификат |
|
||||
| Том | `evobgp_speaker_traefik_letsencrypt` → `/letsencrypt/acme.json` |
|
||||
| CP → нода | `https://AGENT_DOMAIN/v1/agent/*` + `Authorization: Bearer <agent_secret>` + Traefik `ipallowlist` (`PANEL_IP_WHITELIST`) |
|
||||
|
||||
Порты:
|
||||
|
||||
| Порт | Кто | Зачем |
|
||||
|------|-----|-------|
|
||||
| **443** | IP CP (`PANEL_IP_WHITELIST`) | HTTPS dispatch, health, **`GET /v1/agent/bird/protocols`** (live peer sessions) |
|
||||
| **179** | BGP peers | Data plane |
|
||||
| **80** | ACME | Traefik → 443 |
|
||||
| **443** | IP CP (`PANEL_IP_WHITELIST`) | HTTPS dispatch, health, `GET /v1/agent/bird/protocols` |
|
||||
| **179** | BGP peers | Data plane — Docker `ports: 179:179/tcp`, как на панели |
|
||||
| **80** | любой | редирект HTTP → HTTPS (не HTTP-01 ACME) |
|
||||
|
||||
## Подготовка VPS (перед `docker compose up`)
|
||||
В панели хостера / security group откройте **TCP 179** (скрипт compose это не делает). Overlay (`bird_bgp_source_ipv4` / `node_ipv4`) задаёт `router id`; host-сеть bird2 не используется.
|
||||
|
||||
`bird2` — **`network_mode: host`**. Docker **не может** задать `net.ipv4.ip_forward` в таком контейнере; включите на **хосте**:
|
||||
DNS-01 ходит **исходящим** к Cloudflare API и Let's Encrypt; inbound 80 для выпуска сертификата не нужен. Agent слушает `:8443` только во внутренней docker-сети; снаружи — Traefik 443.
|
||||
|
||||
Токен Cloudflare для панели (`evobgp-edge` на CP) в процесс API **не проброшен** — для реплики его задают в форме создания.
|
||||
|
||||
Profile `plain` в файле репозитория — только lab без Traefik.
|
||||
|
||||
## Compose-профили (файл в репозитории)
|
||||
|
||||
| Profile | Состав |
|
||||
|---------|--------|
|
||||
| `production` | bird2 (`speaker-net`, `179:179`) + agent + Traefik LE |
|
||||
| `plain` | bird2 + agent без Traefik (lab; agent на хосте) |
|
||||
| `fallback` | + `sync-bundle` polling (`scripts/sync-bundle.sh`) |
|
||||
|
||||
Команда из UI — самодостаточный yaml **без profiles** (эквивалент production).
|
||||
|
||||
## Подготовка VPS
|
||||
|
||||
`bird2` в docker-сети с `ports: 179:179/tcp` и `sysctls` ip_forward (как панель). Команда из UI дополнительно включает sysctl на хосте:
|
||||
|
||||
```bash
|
||||
sysctl -w net.ipv4.ip_forward=1
|
||||
@@ -63,20 +75,33 @@ echo 'net.ipv6.conf.all.forwarding=1' >> /etc/sysctl.d/99-evobgp-bird.conf
|
||||
sysctl --system
|
||||
```
|
||||
|
||||
## Логи на реплике
|
||||
|
||||
BIRD пишет в stderr (`log stderr all`), agent — в stdout. На VPS:
|
||||
|
||||
```bash
|
||||
cd /opt/evobgp-speaker
|
||||
docker compose logs -f bird2
|
||||
docker compose logs -f evobgp-agent
|
||||
```
|
||||
|
||||
До первого apply бандла с `protocol bgp` порт 179 может быть CLOSED (нет listener). После sync в логах agent: `sync start` / `sync ok` / `sync failed`.
|
||||
|
||||
## Безопасность (три участка)
|
||||
|
||||
1. **CP → реплика:** HTTPS (LE) + Traefik ipallowlist + `agent_secret`.
|
||||
2. **Реплика → CP:** HTTPS + роль `node` (только bundle/latest/enroll).
|
||||
2. **Реплика → CP:** HTTPS + роль `node` (только bundle/latest/enroll). Ключ создаётся вместе со спикером.
|
||||
3. **Конфиг:** Ed25519 `bundle.sig`, SHA-256 manifest, `bird -p`, LKG на ноде.
|
||||
|
||||
Prod checklist:
|
||||
|
||||
- [ ] `EVOBGP_CONTROL_PLANE_URL=https://...`
|
||||
- [ ] `EVOBGP_CONTROL_PLANE_URL=https://...` (в команде из UI)
|
||||
- [ ] `EVOBGP_NODE_DISPATCH_ENABLED=1` на CP
|
||||
- [ ] `EVOBGP_BUNDLE_SEED_HEX` на CP (не менять после выдачи pubkey репликам)
|
||||
- [ ] Уникальные `agent_secret` и node token на спикер
|
||||
- [ ] Не использовать profile `plain` в prod
|
||||
- [ ] Не отключать verify-bundle в agent
|
||||
- [ ] Не `docker compose down -v` без бэкапа `acme.json`
|
||||
|
||||
## Per-speaker BGP source
|
||||
|
||||
@@ -94,27 +119,29 @@ Tenant `/v1/settings` (`bird_bgp_source_ipv4`) — fallback для master / ес
|
||||
|
||||
| Симптом | Проверка |
|
||||
|---------|----------|
|
||||
| `sysctl net.ipv4.ip_forward not allowed in host network` | Уберите sysctls из compose (уже так в main); включите ip_forward на VPS (см. выше) |
|
||||
| `no service selected` | `--profile production` или `COMPOSE_PROFILES=production` |
|
||||
| `CHANGE_ME_*` в yaml | В форме не заполнены email LE / CF token / IP панели / домен |
|
||||
| Traefik отдаёт дефолтный сертификат | DNS only; token `Zone:DNS:Edit`; логи `evobgp-edge`; том acme.json |
|
||||
| Offline в UI | `GET https://AGENT_DOMAIN/v1/agent/health` с CP; LE cert; whitelist |
|
||||
| dispatch error | CP logs job meta; firewall 443; `agent_secret` |
|
||||
| verify-bundle fail | pubkey совпадает с CP seed; пересоберите pubkey после смены seed |
|
||||
| BGP не поднимается | bird2 `network_mode: host`; peers; MD5 BGP отдельно от HTTP sync |
|
||||
| BGP не поднимается / сканер CLOSED | `179:179` в compose; SG хостера; `docker compose logs bird2`; пир MikroTik на IP ноды; бандл применён (`sync ok`) |
|
||||
|
||||
## Ограничения (scale-review)
|
||||
|
||||
- Peers **не** фильтруются по `speaker_id` — один tenant-wide peers fragment на все реплики.
|
||||
- Разные peer-наборы per site — отдельная итерация pipeline.
|
||||
- Если Panel не достучится до agent — включите profile `fallback` (polling).
|
||||
- Если Panel не достучится до agent — включите profile `fallback` (polling) в файле репозитория.
|
||||
|
||||
## Связанные env
|
||||
|
||||
| Переменная | Где |
|
||||
|------------|-----|
|
||||
| `EVOBGP_NODE_DISPATCH_ENABLED=1` | CP |
|
||||
| `EVOBGP_AGENT_SECRET` | реплика |
|
||||
| `EVOBGP_NODE_TOKEN` | реплика |
|
||||
| `EVOBGP_AGENT_SECRET` | реплика (из UI, один раз) |
|
||||
| `EVOBGP_NODE_TOKEN` | реплика (API-ключ role=node, из UI) |
|
||||
| `EVOBGP_FIREWALL_FAILOVER_ENABLED=1` | реплика (опционально: отдавать `/v1/firewall/blocklist` при недоступности CP) |
|
||||
| `EVOBGP_FIREWALL_STATE_FILE` | реплика (default `/var/lib/evobgp-agent/firewall-state.json`) |
|
||||
| `EVOBGP_BUNDLE_PUBKEY_BASE64` | реплика |
|
||||
| `EVOBGP_BUNDLE_PUBKEY_BASE64` | реплика (в команде из UI) |
|
||||
| `PANEL_IP_WHITELIST` | Traefik на реплике |
|
||||
| `CF_DNS_API_TOKEN` | Traefik на реплике |
|
||||
| `LETSENCRYPT_EMAIL` | Traefik на реплике |
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -117,6 +118,9 @@ func (s *Server) handleSync(w http.ResponseWriter, r *http.Request) {
|
||||
if timeout <= 0 {
|
||||
timeout = 45 * time.Second
|
||||
}
|
||||
revID := strings.TrimSpace(req.RevisionID)
|
||||
log.Printf("agentserver: sync start speaker_id=%s revision_id=%q control_plane=%s",
|
||||
strings.TrimSpace(s.cfg.SpeakerID), revID, controlPlaneHost(s.cfg.ControlPlaneURL))
|
||||
ctx, cancel := context.WithTimeout(r.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
@@ -124,7 +128,7 @@ func (s *Server) handleSync(w http.ResponseWriter, r *http.Request) {
|
||||
BaseURL: s.cfg.ControlPlaneURL,
|
||||
Token: s.cfg.NodeToken,
|
||||
SpeakerID: s.cfg.SpeakerID,
|
||||
RevisionID: strings.TrimSpace(req.RevisionID),
|
||||
RevisionID: revID,
|
||||
PubKeyB64: s.cfg.PubKeyB64,
|
||||
PubKeyHex: s.cfg.PubKeyHex,
|
||||
ExtractDir: s.cfg.ExtractDir,
|
||||
@@ -134,13 +138,16 @@ func (s *Server) handleSync(w http.ResponseWriter, r *http.Request) {
|
||||
Timeout: timeout,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("agentserver: sync: %v", err)
|
||||
log.Printf("agentserver: sync failed speaker_id=%s revision_id=%q err=%v",
|
||||
strings.TrimSpace(s.cfg.SpeakerID), revID, err)
|
||||
writeProblem(w, http.StatusBadGateway, upstreamErrorDetail)
|
||||
return
|
||||
}
|
||||
if s.cfg.OnSyncSuccess != nil {
|
||||
s.cfg.OnSyncSuccess(res.RevisionID)
|
||||
}
|
||||
log.Printf("agentserver: sync ok speaker_id=%s applied_revision_id=%s",
|
||||
strings.TrimSpace(s.cfg.SpeakerID), res.RevisionID)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
"applied_revision_id": res.RevisionID,
|
||||
@@ -148,6 +155,18 @@ func (s *Server) handleSync(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
func controlPlaneHost(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || strings.TrimSpace(u.Host) == "" {
|
||||
return raw
|
||||
}
|
||||
return u.Host
|
||||
}
|
||||
|
||||
func (s *Server) authorize(r *http.Request) bool {
|
||||
secret := strings.TrimSpace(s.cfg.Secret)
|
||||
if secret == "" {
|
||||
|
||||
@@ -72,7 +72,8 @@ func RenderMainBirdConf(opts MainBirdConfOptions) (string, error) {
|
||||
}
|
||||
b.WriteString("router id ")
|
||||
b.WriteString(strings.TrimSpace(opts.RouterID))
|
||||
b.WriteString(";\n\n")
|
||||
b.WriteString(";\n")
|
||||
b.WriteString("log stderr all;\n\n")
|
||||
for _, inc := range opts.Includes {
|
||||
inc = strings.TrimSpace(inc)
|
||||
if inc == "" {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
router id 192.0.2.1;
|
||||
log stderr all;
|
||||
|
||||
include "bird.d/evobgp_filters_v4.conf";
|
||||
include "bird.d/evobgp_filters_v6.conf";
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
# Standard EvoBGP layout: main skeleton + bird.d fragments (matches StandardIncludeFragments).
|
||||
|
||||
router id 192.0.2.1;
|
||||
log stderr all;
|
||||
|
||||
include "bird.d/evobgp_filters_v4.conf";
|
||||
include "bird.d/evobgp_filters_v6.conf";
|
||||
|
||||
@@ -999,9 +999,12 @@ func (s *Server) handleNodeBundle(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
frags := rev.PreviewFragments
|
||||
if overlaid, err := pipeline.OverlayFragmentsForSpeaker(s.store, a.TenantID, sid, rid, frags); err == nil {
|
||||
frags = overlaid
|
||||
overlaid, err := pipeline.OverlayFragmentsForSpeaker(s.store, a.TenantID, sid, rid, frags)
|
||||
if err != nil {
|
||||
writeInternalError(w, "bundle overlay", err)
|
||||
return
|
||||
}
|
||||
frags = overlaid
|
||||
tgz, err := bundle.BuildGzippedTar(rid, sid, frags, s.bundlePriv)
|
||||
if err != nil {
|
||||
writeInternalError(w, "internal", err)
|
||||
|
||||
@@ -1129,11 +1129,25 @@ func (s *Server) handlePostSpeaker(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:network:write") {
|
||||
return
|
||||
}
|
||||
var body store.Speaker
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
var req speakerCreateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
||||
return
|
||||
}
|
||||
metaStr, err := metaJSONRawToString(req.MetaJSON)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "meta_json must be a JSON object or string")
|
||||
return
|
||||
}
|
||||
role := strings.TrimSpace(req.Role)
|
||||
if role == "" {
|
||||
role = "replica"
|
||||
}
|
||||
body := store.Speaker{
|
||||
Role: role,
|
||||
Endpoint: strings.TrimSpace(req.Endpoint),
|
||||
MetaJSON: metaStr,
|
||||
}
|
||||
if err := normalizeSpeakerCreate(&body); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
@@ -1148,6 +1162,26 @@ func (s *Server) handlePostSpeaker(w http.ResponseWriter, r *http.Request) {
|
||||
if meta := store.ParseSpeakerMeta(x.MetaJSON); meta.AgentSecret != "" {
|
||||
resp["agent_secret"] = meta.AgentSecret
|
||||
}
|
||||
|
||||
if strings.ToLower(strings.TrimSpace(x.Role)) != "master" {
|
||||
createdKey, kerr := s.store.CreateAPIKey(a.TenantID, &store.APIKeyCreate{
|
||||
Name: "speaker:" + x.ID,
|
||||
Role: "node",
|
||||
})
|
||||
if kerr != nil {
|
||||
_ = s.store.DeleteSpeaker(a.TenantID, x.ID)
|
||||
writeStoreErr(w, kerr)
|
||||
return
|
||||
}
|
||||
if err := s.keyResolver.Reload(s.store); err != nil {
|
||||
writeProblem(w, http.StatusInternalServerError, "Internal Server Error", "failed to reload api keys")
|
||||
return
|
||||
}
|
||||
s.recordCRUDAudit(r, a, "bgp.api_key.create", "Created API key "+createdKey.Name, createdKey.ID, map[string]any{"api_key_id": createdKey.ID, "role": createdKey.Role, "speaker_id": x.ID})
|
||||
resp["node_token"] = createdKey.Token
|
||||
s.attachReplicaInstall(resp, x, createdKey.Token, req, r)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, resp)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,32 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"evobgp/internal/nodedispatch"
|
||||
"evobgp/internal/speakerinstall"
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
var errSpeakerMetaJSONType = errors.New("meta_json must be a JSON object or string")
|
||||
|
||||
type speakerCreateRequest struct {
|
||||
Role string `json:"role"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
MetaJSON json.RawMessage `json:"meta_json"`
|
||||
LetsEncryptEmail string `json:"letsencrypt_email"`
|
||||
CFDNSAPIToken string `json:"cf_dns_api_token"`
|
||||
PanelIPWhitelist string `json:"panel_ip_whitelist"`
|
||||
ControlPlaneURL string `json:"control_plane_url"`
|
||||
}
|
||||
|
||||
func speakerJSONFromStore(st store.Backend, sp *store.Speaker) map[string]any {
|
||||
if sp == nil {
|
||||
return map[string]any{}
|
||||
@@ -123,6 +138,79 @@ func normalizeSpeakerCreate(in *store.Speaker) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func metaJSONRawToString(raw json.RawMessage) (string, error) {
|
||||
t := bytes.TrimSpace(raw)
|
||||
if len(t) == 0 {
|
||||
return "{}", nil
|
||||
}
|
||||
switch t[0] {
|
||||
case '"':
|
||||
var s string
|
||||
if err := json.Unmarshal(t, &s); err != nil {
|
||||
return "", err
|
||||
}
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return "{}", nil
|
||||
}
|
||||
return s, nil
|
||||
case '{':
|
||||
return string(t), nil
|
||||
default:
|
||||
return "", errSpeakerMetaJSONType
|
||||
}
|
||||
}
|
||||
|
||||
func publicControlPlaneURL(r *http.Request, override string) string {
|
||||
if s := strings.TrimSpace(override); s != "" {
|
||||
return strings.TrimRight(s, "/")
|
||||
}
|
||||
if origin := strings.TrimSpace(r.Header.Get("Origin")); strings.HasPrefix(origin, "http://") || strings.HasPrefix(origin, "https://") {
|
||||
return strings.TrimRight(origin, "/")
|
||||
}
|
||||
proto := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto"))
|
||||
if proto == "" {
|
||||
proto = "https"
|
||||
}
|
||||
host := strings.TrimSpace(r.Header.Get("X-Forwarded-Host"))
|
||||
if i := strings.Index(host, ","); i >= 0 {
|
||||
host = strings.TrimSpace(host[:i])
|
||||
}
|
||||
if host == "" {
|
||||
host = strings.TrimSpace(r.Host)
|
||||
}
|
||||
if host == "" {
|
||||
return ""
|
||||
}
|
||||
return proto + "://" + host
|
||||
}
|
||||
|
||||
func (s *Server) attachReplicaInstall(resp map[string]any, sp *store.Speaker, nodeToken string, req speakerCreateRequest, r *http.Request) {
|
||||
if s == nil || sp == nil || resp == nil {
|
||||
return
|
||||
}
|
||||
meta := store.ParseSpeakerMeta(sp.MetaJSON)
|
||||
built, err := speakerinstall.Build(speakerinstall.Params{
|
||||
SpeakerID: sp.ID,
|
||||
AgentSecret: meta.AgentSecret,
|
||||
NodeToken: nodeToken,
|
||||
BundlePubkey: s.BundlePublicKeyBase64(),
|
||||
ControlPlaneURL: publicControlPlaneURL(r, req.ControlPlaneURL),
|
||||
AgentDomain: meta.AgentDomain,
|
||||
LetsEncryptEmail: req.LetsEncryptEmail,
|
||||
CFDNSAPIToken: req.CFDNSAPIToken,
|
||||
PanelIPWhitelist: req.PanelIPWhitelist,
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
resp["bundle_pubkey_base64"] = s.BundlePublicKeyBase64()
|
||||
resp["install"] = map[string]any{
|
||||
"docker_commands": built.DockerCommands,
|
||||
"compose_yaml": built.ComposeYAML,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) recordSpeakerDispatch(tenantID string, sp *store.Speaker, res nodedispatch.Result) {
|
||||
if s == nil || s.store == nil || sp == nil {
|
||||
return
|
||||
|
||||
@@ -33,12 +33,23 @@ func TestPostSpeaker_defaultsFromEndpointIP(t *testing.T) {
|
||||
if out["agent_secret"] == nil || out["agent_secret"] == "" {
|
||||
t.Fatal("expected agent_secret on create")
|
||||
}
|
||||
if out["node_token"] == nil || out["node_token"] == "" {
|
||||
t.Fatal("expected node_token on replica create")
|
||||
}
|
||||
if out["node_ipv4"] != "203.0.113.55" {
|
||||
t.Fatalf("node_ipv4: %#v", out["node_ipv4"])
|
||||
}
|
||||
if out["bird_bgp_source_ipv4"] != "203.0.113.55" {
|
||||
t.Fatalf("bird_bgp_source_ipv4: %#v", out["bird_bgp_source_ipv4"])
|
||||
}
|
||||
install, _ := out["install"].(map[string]any)
|
||||
if install == nil {
|
||||
t.Fatal("expected install on replica create")
|
||||
}
|
||||
cmd, _ := install["docker_commands"].(string)
|
||||
if !strings.Contains(cmd, "traefik") || !strings.Contains(cmd, "dnschallenge") {
|
||||
t.Fatalf("docker_commands missing traefik dns challenge: %s", cmd[:min(200, len(cmd))])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteSpeaker(t *testing.T) {
|
||||
@@ -84,3 +95,104 @@ func TestGetBundleSigningPublicKey(t *testing.T) {
|
||||
t.Fatalf("missing public_key_base64: %#v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostSpeaker_installCommandsAndMetaObject(t *testing.T) {
|
||||
srv, err := New(Options{InsecureDev: true, SeedDemo: true, BundleSeedHex: testBundleSeed})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer srv.Close()
|
||||
tenant, _, _, _, _ := srv.Store().DemoIDs()
|
||||
mustSetTestAPIKeys(t, srv, "edkey|"+tenant+"|editor")
|
||||
|
||||
body := `{
|
||||
"endpoint":"https://bgp-dc2.example.com",
|
||||
"role":"replica",
|
||||
"meta_json":{"agent_domain":"bgp-dc2.example.com","node_ipv4":"203.0.113.10"},
|
||||
"letsencrypt_email":"ops@example.com",
|
||||
"cf_dns_api_token":"cf-token-xyz",
|
||||
"panel_ip_whitelist":"203.0.113.1/32",
|
||||
"control_plane_url":"https://cp.example.com"
|
||||
}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/speakers", strings.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer edkey")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
h := srv.Handler()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id, _ := out["id"].(string)
|
||||
if id == "" {
|
||||
t.Fatal("missing id")
|
||||
}
|
||||
secret, _ := out["agent_secret"].(string)
|
||||
token, _ := out["node_token"].(string)
|
||||
pub, _ := out["bundle_pubkey_base64"].(string)
|
||||
if secret == "" || token == "" || pub == "" {
|
||||
t.Fatalf("missing one-shot secrets: %#v", out)
|
||||
}
|
||||
install, _ := out["install"].(map[string]any)
|
||||
cmd, _ := install["docker_commands"].(string)
|
||||
for _, want := range []string{
|
||||
"traefik",
|
||||
"dnschallenge=true",
|
||||
"dnschallenge.provider=cloudflare",
|
||||
"CF_DNS_API_TOKEN",
|
||||
"cf-token-xyz",
|
||||
"Host(`bgp-dc2.example.com`)",
|
||||
secret,
|
||||
token,
|
||||
"https://cp.example.com",
|
||||
`"179:179/tcp"`,
|
||||
} {
|
||||
if !strings.Contains(cmd, want) {
|
||||
t.Errorf("docker_commands missing %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
get := httptest.NewRequest(http.MethodGet, "/v1/speakers/"+id, nil)
|
||||
get.Header.Set("Authorization", "Bearer edkey")
|
||||
grec := httptest.NewRecorder()
|
||||
h.ServeHTTP(grec, get)
|
||||
if grec.Code != http.StatusOK {
|
||||
t.Fatalf("GET status %d body %s", grec.Code, grec.Body.String())
|
||||
}
|
||||
got := grec.Body.String()
|
||||
if strings.Contains(got, secret) || strings.Contains(got, token) || strings.Contains(got, "docker_commands") {
|
||||
t.Fatalf("GET must not leak install secrets: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostSpeaker_masterSkipsInstall(t *testing.T) {
|
||||
srv, err := New(Options{InsecureDev: true, SeedDemo: true, BundleSeedHex: testBundleSeed})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer srv.Close()
|
||||
tenant, _, _, _, _ := srv.Store().DemoIDs()
|
||||
mustSetTestAPIKeys(t, srv, "edkey|"+tenant+"|editor")
|
||||
|
||||
body := `{"endpoint":"https://127.0.0.1:8080","role":"master"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/speakers", strings.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer edkey")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
if out["node_token"] != nil {
|
||||
t.Fatalf("master must not mint node_token: %#v", out["node_token"])
|
||||
}
|
||||
if out["install"] != nil {
|
||||
t.Fatalf("master must not include install: %#v", out["install"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -19,9 +19,13 @@ func BirdLocalsForSpeaker(st store.Backend, tenantID, speakerID string) birdLoca
|
||||
return loc
|
||||
}
|
||||
meta := store.ParseSpeakerMeta(sp.MetaJSON)
|
||||
if s := strings.TrimSpace(meta.BirdBgpSourceIPv4); s != "" {
|
||||
loc.routerID = s
|
||||
loc.localV4 = s
|
||||
src := strings.TrimSpace(meta.BirdBgpSourceIPv4)
|
||||
if src == "" {
|
||||
src = strings.TrimSpace(meta.NodeIPv4)
|
||||
}
|
||||
if src != "" {
|
||||
loc.routerID = src
|
||||
loc.localV4 = src
|
||||
}
|
||||
if s := strings.TrimSpace(meta.BirdBgpSourceIPv6); s != "" {
|
||||
loc.localV6 = s
|
||||
@@ -32,7 +36,7 @@ func BirdLocalsForSpeaker(st store.Backend, tenantID, speakerID string) birdLoca
|
||||
// OverlayFragmentsForSpeaker re-renders bird.conf and peers fragment with speaker-specific BIRD locals.
|
||||
func OverlayFragmentsForSpeaker(st store.Backend, tenantID, speakerID, revisionID string, frags map[string]string) (map[string]string, error) {
|
||||
if frags == nil {
|
||||
return nil, fmt.Errorf("pipeline: overlay: nil fragments")
|
||||
frags = map[string]string{}
|
||||
}
|
||||
locals := BirdLocalsForSpeaker(st, tenantID, speakerID)
|
||||
out := make(map[string]string, len(frags))
|
||||
|
||||
@@ -40,3 +40,48 @@ func TestOverlayFragmentsForSpeaker_differentRouterID(t *testing.T) {
|
||||
t.Fatalf("sp2 router: %s", out2["bird.conf"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverlayFragmentsForSpeaker_nodeIPv4Fallback(t *testing.T) {
|
||||
m := store.NewMemory()
|
||||
m.SeedDemo()
|
||||
tenant, _, _, _, _ := m.DemoIDs()
|
||||
sp, err := m.CreateSpeaker(tenant, &store.Speaker{
|
||||
Role: "replica",
|
||||
Endpoint: "https://node.example.com",
|
||||
MetaJSON: `{"node_ipv4":"198.51.100.9"}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := pipeline.OverlayFragmentsForSpeaker(m, tenant, sp.ID, "rev1", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out["bird.conf"], "198.51.100.9") {
|
||||
t.Fatalf("expected node_ipv4 as router id, got: %s", out["bird.conf"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverlayFragmentsForSpeaker_sourceOverridesNodeIPv4(t *testing.T) {
|
||||
m := store.NewMemory()
|
||||
m.SeedDemo()
|
||||
tenant, _, _, _, _ := m.DemoIDs()
|
||||
sp, err := m.CreateSpeaker(tenant, &store.Speaker{
|
||||
Role: "replica",
|
||||
Endpoint: "https://node.example.com",
|
||||
MetaJSON: `{"node_ipv4":"198.51.100.9","bird_bgp_source_ipv4":"203.0.113.40"}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := pipeline.OverlayFragmentsForSpeaker(m, tenant, sp.ID, "rev1", map[string]string{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out["bird.conf"], "203.0.113.40") {
|
||||
t.Fatalf("source should win: %s", out["bird.conf"])
|
||||
}
|
||||
if strings.Contains(out["bird.conf"], "198.51.100.9") {
|
||||
t.Fatalf("node_ipv4 should not win over source: %s", out["bird.conf"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
// Package speakerinstall generates a one-shot docker compose snippet for a replica node
|
||||
// (bird2 + evobgp-agent + Traefik Let's Encrypt DNS-01), matching
|
||||
// deploy/compose/docker-compose.remote-speaker.yaml production services.
|
||||
package speakerinstall
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultRegistry = "git.shx.one/denozord"
|
||||
defaultImageTag = "latest"
|
||||
|
||||
placeholderAgentDomain = "CHANGE_ME_AGENT_DOMAIN"
|
||||
placeholderLetsEncryptEmail = "CHANGE_ME_LETSENCRYPT_EMAIL"
|
||||
placeholderCFDNSToken = "CHANGE_ME_CF_DNS_API_TOKEN"
|
||||
placeholderPanelIP = "CHANGE_ME_PANEL_IP"
|
||||
placeholderControlPlaneURL = "CHANGE_ME_CONTROL_PLANE_URL"
|
||||
)
|
||||
|
||||
// Params are values baked into the one-shot compose (not stored on the speaker row).
|
||||
type Params struct {
|
||||
Registry string
|
||||
ImageTag string
|
||||
SpeakerID string
|
||||
AgentSecret string
|
||||
NodeToken string
|
||||
BundlePubkey string
|
||||
ControlPlaneURL string
|
||||
AgentDomain string
|
||||
LetsEncryptEmail string
|
||||
CFDNSAPIToken string
|
||||
PanelIPWhitelist string
|
||||
}
|
||||
|
||||
// Result is the pasteable install payload for POST /v1/speakers 201.
|
||||
type Result struct {
|
||||
ComposeYAML string
|
||||
DockerCommands string
|
||||
}
|
||||
|
||||
type renderData struct {
|
||||
BirdImage string
|
||||
AgentImage string
|
||||
SpeakerID string
|
||||
AgentSecret string
|
||||
NodeToken string
|
||||
BundlePubkey string
|
||||
ControlPlaneURL string
|
||||
AgentDomain string
|
||||
LetsEncryptEmail string
|
||||
CFDNSAPIToken string
|
||||
PanelIPWhitelist string
|
||||
}
|
||||
|
||||
const composeTemplate = `# EvoBGP replica: bird2 + evobgp-agent + Traefik (Let's Encrypt DNS-01 / Cloudflare).
|
||||
# Generated by control plane. Do not commit secrets. ACME state: volume evobgp_speaker_traefik_letsencrypt.
|
||||
# BGP: ports 179:179 like control plane (overlay sets router id). Logs: docker compose logs -f bird2 evobgp-agent
|
||||
|
||||
name: evobgp-remote-speaker
|
||||
|
||||
x-logging: &default-logging
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
services:
|
||||
bird2:
|
||||
image: {{.BirdImage}}
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
sysctls:
|
||||
net.ipv4.ip_forward: "1"
|
||||
net.ipv6.conf.all.forwarding: "1"
|
||||
ports:
|
||||
- "179:179/tcp"
|
||||
volumes:
|
||||
- bird_etc:/etc/bird
|
||||
- bird_run:/run/bird
|
||||
networks:
|
||||
- speaker-net
|
||||
logging: *default-logging
|
||||
|
||||
evobgp-agent:
|
||||
image: {{.AgentImage}}
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- bird2
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
environment:
|
||||
EVOBGP_AGENT_LISTEN: ":8443"
|
||||
EVOBGP_AGENT_SECRET: {{yamlQuote .AgentSecret}}
|
||||
EVOBGP_CONTROL_PLANE_URL: {{yamlQuote .ControlPlaneURL}}
|
||||
EVOBGP_NODE_TOKEN: {{yamlQuote .NodeToken}}
|
||||
EVOBGP_SPEAKER_ID: {{yamlQuote .SpeakerID}}
|
||||
EVOBGP_BUNDLE_PUBKEY_BASE64: {{yamlQuote .BundlePubkey}}
|
||||
EVOBGP_BIRD_EXTRACT_DIR: /etc/bird
|
||||
EVOBGP_BIRDC_SOCKET: /run/bird/bird.ctl
|
||||
volumes:
|
||||
- bird_etc:/etc/bird
|
||||
- bird_run:/run/bird
|
||||
entrypoint: ["/usr/local/bin/evobgp-agent"]
|
||||
command: ["serve", "-socket=/run/bird/bird.ctl"]
|
||||
networks:
|
||||
- speaker-net
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.evobgp-agent.rule={{traefikHost .AgentDomain}}
|
||||
- traefik.http.routers.evobgp-agent.entrypoints=websecure
|
||||
- traefik.http.routers.evobgp-agent.tls=true
|
||||
- traefik.http.routers.evobgp-agent.tls.certresolver=letsencrypt
|
||||
- traefik.http.routers.evobgp-agent.middlewares=panel-ipwhitelist@docker
|
||||
- traefik.http.middlewares.panel-ipwhitelist.ipallowlist.sourcerange={{.PanelIPWhitelist}}
|
||||
- traefik.http.services.evobgp-agent.loadbalancer.server.port=8443
|
||||
logging: *default-logging
|
||||
|
||||
evobgp-edge:
|
||||
image: traefik:latest
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- evobgp-agent
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
environment:
|
||||
DOCKER_API_VERSION: "1.44"
|
||||
CF_DNS_API_TOKEN: {{yamlQuote .CFDNSAPIToken}}
|
||||
command:
|
||||
- --api.dashboard=false
|
||||
- --providers.docker=true
|
||||
- --providers.docker.exposedbydefault=false
|
||||
- --entrypoints.web.address=:80
|
||||
- --entrypoints.websecure.address=:443
|
||||
- --entrypoints.web.http.redirections.entrypoint.to=websecure
|
||||
- --entrypoints.web.http.redirections.entrypoint.scheme=https
|
||||
- --certificatesresolvers.letsencrypt.acme.email={{.LetsEncryptEmail}}
|
||||
- --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json
|
||||
- --certificatesresolvers.letsencrypt.acme.dnschallenge=true
|
||||
- --certificatesresolvers.letsencrypt.acme.dnschallenge.provider=cloudflare
|
||||
- --certificatesresolvers.letsencrypt.acme.dnschallenge.delaybeforecheck=15
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- traefik_letsencrypt:/letsencrypt
|
||||
networks:
|
||||
- speaker-net
|
||||
logging: *default-logging
|
||||
|
||||
networks:
|
||||
speaker-net:
|
||||
|
||||
volumes:
|
||||
bird_etc:
|
||||
bird_run:
|
||||
traefik_letsencrypt:
|
||||
name: evobgp_speaker_traefik_letsencrypt
|
||||
`
|
||||
|
||||
var composeTpl = template.Must(template.New("remote-speaker").Funcs(template.FuncMap{
|
||||
"yamlQuote": yamlDoubleQuote,
|
||||
"traefikHost": traefikHostRule,
|
||||
}).Parse(composeTemplate))
|
||||
|
||||
// Build returns compose YAML and bash docker_commands for a replica VPS.
|
||||
func Build(p Params) (Result, error) {
|
||||
p = normalize(p)
|
||||
data := renderData{
|
||||
BirdImage: p.Registry + "/evobgp-bird2:" + p.ImageTag,
|
||||
AgentImage: p.Registry + "/evobgp-agent:" + p.ImageTag,
|
||||
SpeakerID: p.SpeakerID,
|
||||
AgentSecret: p.AgentSecret,
|
||||
NodeToken: p.NodeToken,
|
||||
BundlePubkey: p.BundlePubkey,
|
||||
ControlPlaneURL: p.ControlPlaneURL,
|
||||
AgentDomain: p.AgentDomain,
|
||||
LetsEncryptEmail: p.LetsEncryptEmail,
|
||||
CFDNSAPIToken: p.CFDNSAPIToken,
|
||||
PanelIPWhitelist: p.PanelIPWhitelist,
|
||||
}
|
||||
var yaml strings.Builder
|
||||
if err := composeTpl.Execute(&yaml, data); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
compose := strings.TrimSpace(yaml.String()) + "\n"
|
||||
return Result{
|
||||
ComposeYAML: compose,
|
||||
DockerCommands: dockerCommands(compose),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalize(p Params) Params {
|
||||
p.Registry = firstNonEmpty(p.Registry, os.Getenv("EVOBGP_REGISTRY"), defaultRegistry)
|
||||
p.ImageTag = firstNonEmpty(p.ImageTag, os.Getenv("EVOBGP_IMAGE_TAG"), defaultImageTag)
|
||||
p.AgentDomain = sanitizeHost(firstNonEmpty(p.AgentDomain, placeholderAgentDomain))
|
||||
p.LetsEncryptEmail = firstNonEmpty(p.LetsEncryptEmail, placeholderLetsEncryptEmail)
|
||||
p.CFDNSAPIToken = firstNonEmpty(p.CFDNSAPIToken, placeholderCFDNSToken)
|
||||
p.PanelIPWhitelist = firstNonEmpty(p.PanelIPWhitelist, placeholderPanelIP)
|
||||
p.ControlPlaneURL = strings.TrimRight(firstNonEmpty(p.ControlPlaneURL, placeholderControlPlaneURL), "/")
|
||||
return p
|
||||
}
|
||||
|
||||
func dockerCommands(composeYAML string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(`# EvoBGP replica: bird2 + agent + Traefik (Let's Encrypt DNS-01 / Cloudflare)
|
||||
# docker login git.shx.one # if images are private
|
||||
# BGP TCP/179 published like the control plane. Cloud security group must allow 179.
|
||||
# Logs: cd /opt/evobgp-speaker && docker compose logs -f bird2 evobgp-agent
|
||||
set -euo pipefail
|
||||
sysctl -w net.ipv4.ip_forward=1
|
||||
sysctl -w net.ipv6.conf.all.forwarding=1
|
||||
mkdir -p /etc/sysctl.d
|
||||
printf '%s\n' 'net.ipv4.ip_forward=1' 'net.ipv6.conf.all.forwarding=1' > /etc/sysctl.d/99-evobgp-bird.conf
|
||||
mkdir -p /opt/evobgp-speaker
|
||||
cat > /opt/evobgp-speaker/docker-compose.yaml <<'EVOBGP_SPEAKER_COMPOSE_EOF'
|
||||
`)
|
||||
b.WriteString(composeYAML)
|
||||
if !strings.HasSuffix(composeYAML, "\n") {
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
b.WriteString("EVOBGP_SPEAKER_COMPOSE_EOF\n")
|
||||
b.WriteString("cd /opt/evobgp-speaker && docker compose up -d\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func traefikHostRule(domain string) string {
|
||||
return "Host(`" + domain + "`)"
|
||||
}
|
||||
|
||||
func yamlDoubleQuote(s string) string {
|
||||
escaped := strings.ReplaceAll(s, `\`, `\\`)
|
||||
escaped = strings.ReplaceAll(escaped, `"`, `\"`)
|
||||
escaped = strings.ReplaceAll(escaped, "\n", `\n`)
|
||||
return `"` + escaped + `"`
|
||||
}
|
||||
|
||||
func sanitizeHost(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
s = strings.TrimPrefix(s, "https://")
|
||||
s = strings.TrimPrefix(s, "http://")
|
||||
if i := strings.IndexAny(s, "/:"); i >= 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
s = strings.ReplaceAll(s, "`", "")
|
||||
s = strings.ReplaceAll(s, `"`, "")
|
||||
s = strings.ReplaceAll(s, "'", "")
|
||||
if s == "" {
|
||||
return placeholderAgentDomain
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if s := strings.TrimSpace(v); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package speakerinstall
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuild_includesTraefikDNS01(t *testing.T) {
|
||||
res, err := Build(Params{
|
||||
SpeakerID: "11111111-1111-1111-1111-111111111111",
|
||||
AgentSecret: "secret-abc",
|
||||
NodeToken: "node-tok",
|
||||
BundlePubkey: "pubkey==",
|
||||
ControlPlaneURL: "https://cp.example.com",
|
||||
AgentDomain: "bgp-dc2.example.com",
|
||||
LetsEncryptEmail: "ops@example.com",
|
||||
CFDNSAPIToken: "cf-token-xyz",
|
||||
PanelIPWhitelist: "203.0.113.1/32",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd := res.DockerCommands
|
||||
for _, want := range []string{
|
||||
"traefik",
|
||||
"dnschallenge=true",
|
||||
"dnschallenge.provider=cloudflare",
|
||||
"CF_DNS_API_TOKEN",
|
||||
"cf-token-xyz",
|
||||
"Host(`bgp-dc2.example.com`)",
|
||||
"secret-abc",
|
||||
"node-tok",
|
||||
"https://cp.example.com",
|
||||
"docker compose up -d",
|
||||
"sysctl -w net.ipv4.ip_forward=1",
|
||||
"evobgp_speaker_traefik_letsencrypt",
|
||||
`"179:179/tcp"`,
|
||||
"net.ipv4.ip_forward: \"1\"",
|
||||
} {
|
||||
if !strings.Contains(cmd, want) {
|
||||
t.Errorf("docker_commands missing %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(cmd, "network_mode: host") {
|
||||
t.Error("replica bird2 must not use network_mode: host")
|
||||
}
|
||||
if strings.Contains(cmd, "?set ") {
|
||||
t.Error("compose must bake values, not ${VAR:?set VAR}")
|
||||
}
|
||||
if res.ComposeYAML == "" {
|
||||
t.Fatal("compose_yaml empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuild_placeholdersWhenEmpty(t *testing.T) {
|
||||
res, err := Build(Params{SpeakerID: "id", AgentSecret: "s", NodeToken: "t", BundlePubkey: "p"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, ph := range []string{
|
||||
placeholderAgentDomain,
|
||||
placeholderLetsEncryptEmail,
|
||||
placeholderCFDNSToken,
|
||||
placeholderPanelIP,
|
||||
placeholderControlPlaneURL,
|
||||
} {
|
||||
if !strings.Contains(res.DockerCommands, ph) {
|
||||
t.Errorf("expected placeholder %s", ph)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -13,10 +13,12 @@
|
||||
"@commitlint/cli": "^19.8.1",
|
||||
"@commitlint/config-conventional": "^19.8.1",
|
||||
"@markwylde/semantic-release-gitea": "^2.2.0",
|
||||
"@redocly/cli": "1.34.5",
|
||||
"@semantic-release/changelog": "^6.0.3",
|
||||
"@semantic-release/commit-analyzer": "^13.0.1",
|
||||
"@semantic-release/exec": "^7.0.0",
|
||||
"@semantic-release/release-notes-generator": "^14.0.3",
|
||||
"conventional-commits-parser": "^6.4.0",
|
||||
"semantic-release": "^25.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+1312
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env sh
|
||||
# Абсолютные пути для actions/cache: на act_runner «~» часто не раскрывается → cache miss.
|
||||
set -eu
|
||||
: "${GITHUB_ENV:?GITHUB_ENV required (Gitea/GitHub Actions)}"
|
||||
|
||||
HOME_DIR="${HOME:-}"
|
||||
if [ -z "$HOME_DIR" ]; then
|
||||
HOME_DIR="$(getent passwd "$(id -u)" 2>/dev/null | cut -d: -f6 || true)"
|
||||
fi
|
||||
HOME_DIR="${HOME_DIR:-/root}"
|
||||
|
||||
if command -v go >/dev/null 2>&1; then
|
||||
GOMODCACHE="$(go env GOMODCACHE)"
|
||||
GOCACHE="$(go env GOCACHE)"
|
||||
GOPATH="$(go env GOPATH)"
|
||||
else
|
||||
GOMODCACHE="${HOME_DIR}/go/pkg/mod"
|
||||
GOCACHE="${HOME_DIR}/.cache/go-build"
|
||||
GOPATH="${HOME_DIR}/go"
|
||||
fi
|
||||
|
||||
PNPM_STORE_DIR="${HOME_DIR}/.pnpm-store"
|
||||
COREPACK_HOME="${HOME_DIR}/.cache/node/corepack"
|
||||
GOBIN="${GOPATH}/bin"
|
||||
GOLANGCI_LINT_CACHE="${HOME_DIR}/.cache/golangci-lint"
|
||||
|
||||
mkdir -p "$PNPM_STORE_DIR" "$COREPACK_HOME" "$GOMODCACHE" "$GOCACHE" "$GOBIN" "$GOLANGCI_LINT_CACHE"
|
||||
|
||||
{
|
||||
echo "HOME_DIR=${HOME_DIR}"
|
||||
echo "PNPM_STORE_DIR=${PNPM_STORE_DIR}"
|
||||
echo "COREPACK_HOME=${COREPACK_HOME}"
|
||||
echo "GOMODCACHE=${GOMODCACHE}"
|
||||
echo "GOCACHE=${GOCACHE}"
|
||||
echo "GOPATH=${GOPATH}"
|
||||
echo "GOBIN=${GOBIN}"
|
||||
echo "GOLANGCI_LINT_CACHE=${GOLANGCI_LINT_CACHE}"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
echo "cache-env HOME_DIR=${HOME_DIR}"
|
||||
echo "cache-env PNPM_STORE_DIR=${PNPM_STORE_DIR}"
|
||||
echo "cache-env GOMODCACHE=${GOMODCACHE}"
|
||||
echo "cache-env GOCACHE=${GOCACHE}"
|
||||
echo "cache-env GOBIN=${GOBIN}"
|
||||
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env sh
|
||||
# Бинарь golangci-lint в GOBIN (кэш actions/cache), без goinstall и без hashFiles.
|
||||
set -eu
|
||||
VER="${GOLANGCI_LINT_VERSION:-v1.64.8}"
|
||||
: "${GOBIN:?GOBIN required — run scripts/ci/export-cache-env.sh after setup-go}"
|
||||
|
||||
mkdir -p "$GOBIN"
|
||||
export PATH="${GOBIN}:${PATH}"
|
||||
if [ -n "${GOLANGCI_LINT_CACHE:-}" ]; then
|
||||
mkdir -p "$GOLANGCI_LINT_CACHE"
|
||||
export GOLANGCI_LINT_CACHE
|
||||
fi
|
||||
|
||||
if [ ! -x "${GOBIN}/golangci-lint" ]; then
|
||||
echo "install golangci-lint ${VER} -> ${GOBIN}"
|
||||
curl -sSfL "https://raw.githubusercontent.com/golangci/golangci-lint/${VER}/install.sh" \
|
||||
| sh -s -- -b "$GOBIN" "$VER"
|
||||
fi
|
||||
golangci-lint version
|
||||
golangci-lint run
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env sh
|
||||
# pnpm install из кэша Gitea (store + node_modules). Без повторного download при hit.
|
||||
set -eu
|
||||
: "${PNPM_STORE_DIR:?PNPM_STORE_DIR required — run scripts/ci/export-cache-env.sh first}"
|
||||
|
||||
export COREPACK_HOME="${COREPACK_HOME:-${HOME:-/root}/.cache/node/corepack}"
|
||||
mkdir -p "$PNPM_STORE_DIR" "$COREPACK_HOME"
|
||||
corepack enable
|
||||
pnpm config set store-dir "$PNPM_STORE_DIR"
|
||||
|
||||
echo "pnpm store: $(pnpm store path)"
|
||||
echo "PNPM_CACHE_HIT=${PNPM_CACHE_HIT:-}"
|
||||
|
||||
if [ "${PNPM_CACHE_HIT:-}" = "true" ]; then
|
||||
if pnpm install --frozen-lockfile --offline; then
|
||||
echo "pnpm install --offline (cache hit)"
|
||||
exit 0
|
||||
fi
|
||||
echo "offline install failed — prefer-offline"
|
||||
fi
|
||||
pnpm install --frozen-lockfile --prefer-offline
|
||||
@@ -4,7 +4,9 @@
|
||||
* Exit 0 always — semantic-release still decides release/no-op.
|
||||
*/
|
||||
import { execSync } from 'node:child_process';
|
||||
import parser from 'conventional-commits-parser';
|
||||
import { CommitParser } from 'conventional-commits-parser';
|
||||
|
||||
const parser = new CommitParser();
|
||||
|
||||
const RELEASABLE = new Set(['feat', 'fix', 'perf', 'ci', 'refactor']);
|
||||
|
||||
@@ -32,8 +34,14 @@ const unparseable = [];
|
||||
const releasable = [];
|
||||
|
||||
for (const { hash, subject } of commits) {
|
||||
const parsed = parser.sync(subject);
|
||||
if (!parsed.type) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = parser.parse(subject);
|
||||
} catch {
|
||||
unparseable.push({ hash: hash.slice(0, 7), subject });
|
||||
continue;
|
||||
}
|
||||
if (!parsed?.type) {
|
||||
unparseable.push({ hash: hash.slice(0, 7), subject });
|
||||
continue;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user