diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 0433e76..c41c661 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -186,8 +186,11 @@ jobs: # Docker: Go-бинарники (api, all, scheduler, ingest, render, deploy, node, agent). # Запускается если изменился Go-код или Go-Dockerfile. # Если Go-код менялся — требуем успех go-тестов; если только Dockerfile — go skipped, ОК. + # + # docker-go-prime: один раз собирает stage `deps` (go mod download) и пишет BuildKit cache + # в registry — матрица docker-go не качает модули восемь раз подряд. # --------------------------------------------------------------------------- - docker-go: + docker-go-prime: needs: [changes, go] if: >- always() && @@ -197,6 +200,49 @@ jobs: (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') && (needs.changes.outputs.go == 'true' || needs.changes.outputs.docker_go == 'true') runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Prepare image metadata + id: meta + run: | + set -euo pipefail + owner_lc="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" + echo "owner_lc=$owner_lc" >> "$GITHUB_OUTPUT" + - name: Log in to Gitea Registry + uses: docker/login-action@v3 + with: + registry: git.shts.su + username: ${{ gitea.actor }} + password: ${{ secrets.ACTIONS_PAT || gitea.token }} + - name: Prime Go module layer (deps) + env: + OWNER_LC: ${{ steps.meta.outputs.owner_lc }} + run: | + set -euxo pipefail + WS="${{ github.workspace }}" + CACHE_REF="git.shts.su/${OWNER_LC}/evobgp-buildcache:go-buildcache" + cd "$WS" + docker buildx build \ + --platform linux/amd64 \ + --file deploy/docker/gobinary/Dockerfile \ + --target deps \ + --cache-from "type=registry,ref=${CACHE_REF}" \ + --cache-to "type=registry,ref=${CACHE_REF},mode=max" \ + "$WS" + + docker-go: + needs: [changes, go, docker-go-prime] + if: >- + always() && + needs.changes.result == 'success' && + needs.go.result != 'failure' && + needs.docker-go-prime.result == 'success' && + github.event_name == 'push' && + (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') && + (needs.changes.outputs.go == 'true' || needs.changes.outputs.docker_go == 'true') + runs-on: ubuntu-latest strategy: fail-fast: false matrix: @@ -271,6 +317,7 @@ jobs: WS="${{ github.workspace }}" cd "$WS" + CACHE_REF="git.shts.su/${OWNER_LC}/evobgp-buildcache:go-buildcache" IMG="git.shts.su/${OWNER_LC}/${IMAGE}" BUILD_ARGS=() if [ -n "${BIN:-}" ]; then @@ -285,6 +332,8 @@ jobs: --platform linux/amd64 \ --file "$DF" \ "${BUILD_ARGS[@]}" \ + --cache-from "type=registry,ref=${CACHE_REF}" \ + --cache-to "type=registry,ref=${CACHE_REF},mode=max" \ --tag "${IMG}:latest" \ --tag "${IMG}:${SHORT_SHA}" \ --tag "${IMG}:sha-${{ github.sha }}" \ diff --git a/deploy/docker/evobgp-agent/Dockerfile b/deploy/docker/evobgp-agent/Dockerfile index cc44dfc..f92d4a7 100644 --- a/deploy/docker/evobgp-agent/Dockerfile +++ b/deploy/docker/evobgp-agent/Dockerfile @@ -1,8 +1,12 @@ # evobgp-agent + bird2 из репозитория Ubuntu Noble (тот же стек, что evobgp-bird2). # См. gobinary/Dockerfile — ECR Public вместо прямого pull с Docker Hub. -FROM public.ecr.aws/docker/library/golang:1.22-bookworm AS build +# Порядок слоёв как в gobinary: кэш модулей отдельно от исходников (CI/CD BuildKit). +FROM public.ecr.aws/docker/library/golang:1.22-bookworm AS deps WORKDIR /src COPY go.mod go.sum ./ +RUN go mod download + +FROM deps AS build COPY . . RUN go build -trimpath -ldflags="-s -w" -o /out/evobgp-agent ./cmd/evobgp-agent diff --git a/deploy/docker/gobinary/Dockerfile b/deploy/docker/gobinary/Dockerfile index 125d126..c1a508f 100644 --- a/deploy/docker/gobinary/Dockerfile +++ b/deploy/docker/gobinary/Dockerfile @@ -1,10 +1,13 @@ # Универсальная сборка бинаря из cmd/* (ARG BIN=evobgp-api | evobgp-all). # INSTALL_BIRDC=1 собирает BIRD 2.14 из исходников (birdc) для EVOBGP_BIRDC_SOCKET; иначе пакет Debian не используется. # Базовые образы из ECR Public (официальное зеркало library/*), чтобы CI не зависел от auth.docker.io. -FROM public.ecr.aws/docker/library/golang:1.22-bookworm AS build +# Отдельный stage для кэша модулей (CI: один раз --target deps, затем параллельные сборки с cache-from). +FROM public.ecr.aws/docker/library/golang:1.22-bookworm AS deps WORKDIR /src COPY go.mod go.sum ./ RUN go mod download + +FROM deps AS build COPY . . ARG BIN=evobgp-api RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/evobgp ./cmd/${BIN} diff --git a/docs/openapi.yaml b/docs/openapi.yaml index e3b4186..cdfabb8 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -397,6 +397,9 @@ components: source_kind: type: string description: Формат скачанного списка / парсер. + prefix_path: + type: string + description: Путь до поля с префиксами для source_kind=json (например data.items[].cidr). community_id: type: ["string", "null"] refresh_interval_sec: @@ -413,6 +416,8 @@ components: format: uri source_kind: type: string + prefix_path: + type: string community_id: type: ["string", "null"] @@ -424,11 +429,40 @@ components: format: uri source_kind: type: string + prefix_path: + type: string community_id: type: ["string", "null"] refresh_interval_sec: type: ["integer", "null"] + CdnPreviewRequest: + type: object + required: [url, source_kind] + properties: + url: + type: string + format: uri + source_kind: + type: string + prefix_path: + type: string + + CdnPreviewResponse: + type: object + required: [items, total, truncated, source_url] + properties: + items: + type: array + items: + type: string + total: + type: integer + truncated: + type: boolean + source_url: + type: string + AsEntry: type: object required: @@ -1535,6 +1569,40 @@ paths: default: $ref: "#/components/responses/DefaultProblem" + /v1/modules/{module_id}/cdn-sources/preview: + parameters: + - $ref: "#/components/parameters/TenantId" + - $ref: "#/components/parameters/ModuleId" + post: + tags: [Modules] + summary: Предпросмотр префиксов из CDN-источника + description: > + Загружает URL, парсит как plaintext или json и возвращает список извлечённых префиксов (до 100 записей). + operationId: previewCdnSource + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CdnPreviewRequest" + responses: + "200": + description: Успешно. + content: + application/json: + schema: + $ref: "#/components/schemas/CdnPreviewResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "422": + $ref: "#/components/responses/UnprocessableEntity" + default: + $ref: "#/components/responses/DefaultProblem" + /v1/doh-profiles/{id}: parameters: - $ref: "#/components/parameters/TenantId" diff --git a/internal/httpapi/routes_crud.go b/internal/httpapi/routes_crud.go index 775b9a8..c9871fc 100644 --- a/internal/httpapi/routes_crud.go +++ b/internal/httpapi/routes_crud.go @@ -2,11 +2,14 @@ package httpapi import ( "encoding/json" + "fmt" + "io" "net/http" "strconv" "strings" "time" + "evobgp/internal/pipeline" "evobgp/internal/store" ) @@ -17,6 +20,7 @@ func (s *Server) registerCRUDRoutes(m *http.ServeMux) { m.HandleFunc("GET /modules/{module_id}/cdn-sources", s.handleListCDNSources) m.HandleFunc("POST /modules/{module_id}/cdn-sources", s.handlePostCDNSource) + m.HandleFunc("POST /modules/{module_id}/cdn-sources/preview", s.handlePreviewCDNSource) m.HandleFunc("PATCH /modules/{module_id}/cdn-sources/{source_id}", s.handlePatchCDNSource) m.HandleFunc("DELETE /modules/{module_id}/cdn-sources/{source_id}", s.handleDeleteCDNSource) @@ -68,14 +72,14 @@ func (s *Server) handlePostModule(w http.ResponseWriter, r *http.Request) { return } var body struct { - Type string `json:"type"` - Name string `json:"name"` - Enabled bool `json:"enabled"` - Priority int `json:"priority"` - RefreshIntervalSec int `json:"refresh_interval_sec"` - CronExpr string `json:"cron_expr"` - DefaultCommunityID *string `json:"default_community_id"` - DohProfileID *string `json:"doh_profile_id"` + Type string `json:"type"` + Name string `json:"name"` + Enabled bool `json:"enabled"` + Priority int `json:"priority"` + RefreshIntervalSec int `json:"refresh_interval_sec"` + CronExpr string `json:"cron_expr"` + DefaultCommunityID *string `json:"default_community_id"` + DohProfileID *string `json:"doh_profile_id"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json") @@ -153,7 +157,7 @@ func (s *Server) handleListCDNSources(w http.ResponseWriter, r *http.Request) { } func cdnSourceJSON(x *store.CDNSource) map[string]any { - m := map[string]any{"id": x.ID, "source_kind": x.SourceKind, "url": x.URL, "etag": x.Etag} + m := map[string]any{"id": x.ID, "source_kind": x.SourceKind, "url": x.URL, "prefix_path": x.PrefixPath, "etag": x.Etag} if x.RefreshIntervalSec != nil { m["refresh_interval_sec"] = *x.RefreshIntervalSec } else { @@ -167,6 +171,76 @@ func cdnSourceJSON(x *store.CDNSource) map[string]any { return m } +func (s *Server) handlePreviewCDNSource(w http.ResponseWriter, r *http.Request) { + a, ok := authFromContext(r.Context()) + if !ok || !s.requireAtLeast(w, a, "editor") { + return + } + var body struct { + URL string `json:"url"` + SourceKind string `json:"source_kind"` + PrefixPath string `json:"prefix_path"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json") + return + } + u := strings.TrimSpace(body.URL) + if u == "" { + writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "url is required") + return + } + mod, err := s.store.GetModule(a.TenantID, r.PathValue("module_id")) + if err != nil { + writeStoreErr(w, err) + return + } + if mod.Type != "CDN_CIDRS" { + writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "module type must be CDN_CIDRS") + return + } + req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, u, nil) + if err != nil { + writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "invalid url") + return + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + writeProblem(w, http.StatusBadGateway, "Bad Gateway", err.Error()) + return + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + _, _ = io.Copy(io.Discard, resp.Body) + writeProblem(w, http.StatusBadGateway, "Bad Gateway", fmt.Sprintf("upstream status: %s", resp.Status)) + return + } + raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) + if err != nil { + writeProblem(w, http.StatusBadGateway, "Bad Gateway", err.Error()) + return + } + pfxs, err := pipeline.ExtractCIDRs(string(raw), body.SourceKind, body.PrefixPath) + if err != nil { + writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", err.Error()) + return + } + items := make([]string, 0, len(pfxs)) + const previewLimit = 100 + for i, p := range pfxs { + if i >= previewLimit { + break + } + items = append(items, p.String()) + } + writeJSON(w, http.StatusOK, map[string]any{ + "items": items, + "total": len(pfxs), + "truncated": len(pfxs) > previewLimit, + "source_url": u, + }) +} + func (s *Server) handlePostCDNSource(w http.ResponseWriter, r *http.Request) { a, ok := authFromContext(r.Context()) if !ok || !s.requireAtLeast(w, a, "editor") { diff --git a/internal/pipeline/parse.go b/internal/pipeline/parse.go index d1b495c..6927c2d 100644 --- a/internal/pipeline/parse.go +++ b/internal/pipeline/parse.go @@ -2,6 +2,7 @@ package pipeline import ( "bufio" + "encoding/json" "net/netip" "strings" ) @@ -31,6 +32,105 @@ func ParseCIDRLines(body string) []netip.Prefix { return out } +// ExtractCIDRs parses CIDRs from either plaintext lines or JSON payload. +// For sourceKind="json", prefixPath supports dotted traversal, with [] for arrays: +// e.g. "prefixes[]", "data.items[].cidr". +func ExtractCIDRs(body, sourceKind, prefixPath string) ([]netip.Prefix, error) { + if strings.EqualFold(strings.TrimSpace(sourceKind), "json") { + return parseCIDRsFromJSON(body, prefixPath) + } + return ParseCIDRLines(body), nil +} + +func parseCIDRsFromJSON(body, prefixPath string) ([]netip.Prefix, error) { + var root any + if err := json.Unmarshal([]byte(body), &root); err != nil { + return nil, err + } + values := jsonValuesAtPath(root, prefixPath) + seen := make(map[string]struct{}) + var out []netip.Prefix + for _, raw := range values { + pfx := parseOneCIDR(raw) + if !pfx.IsValid() { + continue + } + m := pfx.Masked() + s := m.String() + if _, ok := seen[s]; ok { + continue + } + seen[s] = struct{}{} + out = append(out, m) + } + return out, nil +} + +func jsonValuesAtPath(root any, prefixPath string) []string { + path := strings.TrimSpace(prefixPath) + if path == "" { + return flattenJSONStrings(root) + } + parts := strings.Split(path, ".") + nodes := []any{root} + for _, p := range parts { + part := strings.TrimSpace(p) + if part == "" { + continue + } + iter := strings.HasSuffix(part, "[]") + key := strings.TrimSuffix(part, "[]") + var next []any + for _, n := range nodes { + obj, ok := n.(map[string]any) + if !ok { + continue + } + child, ok := obj[key] + if !ok { + continue + } + if iter { + if arr, ok := child.([]any); ok { + next = append(next, arr...) + } + continue + } + next = append(next, child) + } + nodes = next + if len(nodes) == 0 { + return nil + } + } + var out []string + for _, n := range nodes { + out = append(out, flattenJSONStrings(n)...) + } + return out +} + +func flattenJSONStrings(v any) []string { + switch x := v.(type) { + case string: + return []string{strings.TrimSpace(x)} + case []any: + var out []string + for _, item := range x { + out = append(out, flattenJSONStrings(item)...) + } + return out + case map[string]any: + var out []string + for _, item := range x { + out = append(out, flattenJSONStrings(item)...) + } + return out + default: + return nil + } +} + func parseOneCIDR(s string) netip.Prefix { if p, err := netip.ParsePrefix(s); err == nil { return p diff --git a/internal/pipeline/refresh.go b/internal/pipeline/refresh.go index b55c9ec..7930474 100644 --- a/internal/pipeline/refresh.go +++ b/internal/pipeline/refresh.go @@ -182,7 +182,11 @@ func collectModulePrefixRows(ctx context.Context, st store.Backend, hc *http.Cli e := etag _, _ = st.UpdateCDNSource(tenantID, moduleID, src.ID, &store.CDNSourcePatch{Etag: &e}) } - for _, pfx := range ParseCIDRLines(string(body)) { + pfxs, err := ExtractCIDRs(string(body), src.SourceKind, src.PrefixPath) + if err != nil { + return nil, fmt.Errorf("cdn parse %s: %w", u, err) + } + for _, pfx := range pfxs { comm := src.CommunityID if comm == nil && mod.DefaultCommunityID != nil { c := *mod.DefaultCommunityID diff --git a/internal/repository/postgres_entities.go b/internal/repository/postgres_entities.go index 4545534..56b892e 100644 --- a/internal/repository/postgres_entities.go +++ b/internal/repository/postgres_entities.go @@ -23,7 +23,7 @@ func (p *Postgres) ListCDNSources(tenantID, moduleID string) ([]*store.CDNSource } ctx := context.Background() rows, err := p.pool.Query(ctx, ` - SELECT id::text, source_kind, url, COALESCE(etag,''), refresh_interval_sec, community_id::text + SELECT id::text, source_kind, url, COALESCE(prefix_path,''), COALESCE(etag,''), refresh_interval_sec, community_id::text FROM module_cdn_source WHERE module_id=$1 ORDER BY url`, moduleID) if err != nil { return nil, err @@ -35,7 +35,7 @@ func (p *Postgres) ListCDNSources(tenantID, moduleID string) ([]*store.CDNSource s.ModuleID = moduleID var ri *int32 var comm *string - if err := rows.Scan(&s.ID, &s.SourceKind, &s.URL, &s.Etag, &ri, &comm); err != nil { + if err := rows.Scan(&s.ID, &s.SourceKind, &s.URL, &s.PrefixPath, &s.Etag, &ri, &comm); err != nil { continue } if ri != nil { @@ -62,9 +62,9 @@ func (p *Postgres) CreateCDNSource(tenantID, moduleID string, in *store.CDNSourc ctx := context.Background() id := uuid.NewString() _, err = p.pool.Exec(ctx, ` - INSERT INTO module_cdn_source (id, module_id, source_kind, url, etag, refresh_interval_sec, community_id) - VALUES ($1,$2,$3,$4,$5,$6, NULLIF($7::uuid, '00000000-0000-0000-0000-000000000000'::uuid))`, - id, moduleID, in.SourceKind, strings.TrimSpace(in.URL), in.Etag, nullInt32Ptr(in.RefreshIntervalSec), uuidOrNilPtr(in.CommunityID)) + INSERT INTO module_cdn_source (id, module_id, source_kind, url, prefix_path, etag, refresh_interval_sec, community_id) + VALUES ($1,$2,$3,$4,$5,$6,$7, NULLIF($8::uuid, '00000000-0000-0000-0000-000000000000'::uuid))`, + id, moduleID, in.SourceKind, strings.TrimSpace(in.URL), strings.TrimSpace(in.PrefixPath), in.Etag, nullInt32Ptr(in.RefreshIntervalSec), uuidOrNilPtr(in.CommunityID)) if err != nil { return nil, err } @@ -77,8 +77,8 @@ func (p *Postgres) getCDNSource(ctx context.Context, moduleID, id string) (*stor var ri *int32 var comm *string err := p.pool.QueryRow(ctx, ` - SELECT id::text, source_kind, url, COALESCE(etag,''), refresh_interval_sec, community_id::text - FROM module_cdn_source WHERE id=$1 AND module_id=$2`, id, moduleID).Scan(&s.ID, &s.SourceKind, &s.URL, &s.Etag, &ri, &comm) + SELECT id::text, source_kind, url, COALESCE(prefix_path,''), COALESCE(etag,''), refresh_interval_sec, community_id::text + FROM module_cdn_source WHERE id=$1 AND module_id=$2`, id, moduleID).Scan(&s.ID, &s.SourceKind, &s.URL, &s.PrefixPath, &s.Etag, &ri, &comm) if err != nil { return nil, err } @@ -114,6 +114,9 @@ func (p *Postgres) UpdateCDNSource(tenantID, moduleID, sourceID string, patch *s if patch.URL != nil { cur.URL = strings.TrimSpace(*patch.URL) } + if patch.PrefixPath != nil { + cur.PrefixPath = strings.TrimSpace(*patch.PrefixPath) + } if patch.Etag != nil { cur.Etag = *patch.Etag } @@ -130,10 +133,10 @@ func (p *Postgres) UpdateCDNSource(tenantID, moduleID, sourceID string, patch *s } ctx := context.Background() _, err = p.pool.Exec(ctx, ` - UPDATE module_cdn_source SET source_kind=$3, url=$4, etag=$5, refresh_interval_sec=$6, - community_id=NULLIF($7::uuid, '00000000-0000-0000-0000-000000000000'::uuid), updated_at=now() + UPDATE module_cdn_source SET source_kind=$3, url=$4, prefix_path=$5, etag=$6, refresh_interval_sec=$7, + community_id=NULLIF($8::uuid, '00000000-0000-0000-0000-000000000000'::uuid), updated_at=now() WHERE id=$1 AND module_id=$2`, - sourceID, moduleID, cur.SourceKind, cur.URL, cur.Etag, nullInt32Ptr(cur.RefreshIntervalSec), uuidOrNilPtr(cur.CommunityID)) + sourceID, moduleID, cur.SourceKind, cur.URL, cur.PrefixPath, cur.Etag, nullInt32Ptr(cur.RefreshIntervalSec), uuidOrNilPtr(cur.CommunityID)) if err != nil { return nil, err } diff --git a/internal/store/backend.go b/internal/store/backend.go index 04d4136..38c197e 100644 --- a/internal/store/backend.go +++ b/internal/store/backend.go @@ -82,29 +82,31 @@ type Backend interface { // ModulePatch is a partial update for module. type ModulePatch struct { - Name *string `json:"name,omitempty"` - Enabled *bool `json:"enabled,omitempty"` - Priority *int `json:"priority,omitempty"` - RefreshIntervalSec *int `json:"refresh_interval_sec,omitempty"` - CronExpr *string `json:"cron_expr,omitempty"` - DefaultCommunityID *string `json:"default_community_id,omitempty"` - DohProfileID *string `json:"doh_profile_id,omitempty"` + Name *string `json:"name,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + Priority *int `json:"priority,omitempty"` + RefreshIntervalSec *int `json:"refresh_interval_sec,omitempty"` + CronExpr *string `json:"cron_expr,omitempty"` + DefaultCommunityID *string `json:"default_community_id,omitempty"` + DohProfileID *string `json:"doh_profile_id,omitempty"` } // CDNSource is a row under a CDN module. type CDNSource struct { - ID string `json:"id,omitempty"` - ModuleID string `json:"module_id,omitempty"` - SourceKind string `json:"source_kind"` - URL string `json:"url"` - Etag string `json:"etag"` - RefreshIntervalSec *int `json:"refresh_interval_sec"` - CommunityID *string `json:"community_id"` + ID string `json:"id,omitempty"` + ModuleID string `json:"module_id,omitempty"` + SourceKind string `json:"source_kind"` + URL string `json:"url"` + PrefixPath string `json:"prefix_path,omitempty"` + Etag string `json:"etag"` + RefreshIntervalSec *int `json:"refresh_interval_sec"` + CommunityID *string `json:"community_id"` } type CDNSourcePatch struct { SourceKind *string `json:"source_kind,omitempty"` URL *string `json:"url,omitempty"` + PrefixPath *string `json:"prefix_path,omitempty"` Etag *string `json:"etag,omitempty"` RefreshIntervalSec *int `json:"refresh_interval_sec,omitempty"` CommunityID *string `json:"community_id,omitempty"` @@ -131,10 +133,10 @@ func ValidASN(n int64) bool { } type DomainEntry struct { - ID string `json:"id,omitempty"` - ModuleID string `json:"module_id,omitempty"` - FQDN string `json:"fqdn"` - CommunityID *string `json:"community_id"` + ID string `json:"id,omitempty"` + ModuleID string `json:"module_id,omitempty"` + FQDN string `json:"fqdn"` + CommunityID *string `json:"community_id"` } type DomainEntryPatch struct { @@ -143,10 +145,10 @@ type DomainEntryPatch struct { } type IPRangeEntry struct { - ID string `json:"id,omitempty"` - ModuleID string `json:"module_id,omitempty"` - Prefix string `json:"prefix"` - CommunityID *string `json:"community_id"` + ID string `json:"id,omitempty"` + ModuleID string `json:"module_id,omitempty"` + Prefix string `json:"prefix"` + CommunityID *string `json:"community_id"` } type IPRangePatch struct { @@ -155,12 +157,12 @@ type IPRangePatch struct { } type DohProfile struct { - ID string `json:"id,omitempty"` - TenantID string `json:"tenant_id,omitempty"` - Name string `json:"name"` - URL string `json:"url"` - TimeoutMs *int `json:"timeout_ms"` - SecretRef *string `json:"vault_secret_ref"` + ID string `json:"id,omitempty"` + TenantID string `json:"tenant_id,omitempty"` + Name string `json:"name"` + URL string `json:"url"` + TimeoutMs *int `json:"timeout_ms"` + SecretRef *string `json:"vault_secret_ref"` } type DohProfilePatch struct { @@ -202,7 +204,7 @@ type SpeakerPatch struct { // PrefixRow is one materialized prefix for GET /revisions/.../prefixes. type PrefixRow struct { - Prefix string - CommunityID *string - Source string + Prefix string + CommunityID *string + Source string } diff --git a/internal/store/memory_crud.go b/internal/store/memory_crud.go index 7a35b11..93168d9 100644 --- a/internal/store/memory_crud.go +++ b/internal/store/memory_crud.go @@ -22,16 +22,16 @@ func (m *Memory) CreateModule(tenantID string, in *Module) (*Module, error) { } id := uuid.NewString() mod := &Module{ - ID: id, - TenantID: tenantID, - Type: in.Type, - Name: strings.TrimSpace(in.Name), - Enabled: in.Enabled, - Priority: in.Priority, - RefreshIntervalSec: in.RefreshIntervalSec, - CronExpr: in.CronExpr, - DefaultCommunityID: in.DefaultCommunityID, - DohProfileID: in.DohProfileID, + ID: id, + TenantID: tenantID, + Type: in.Type, + Name: strings.TrimSpace(in.Name), + Enabled: in.Enabled, + Priority: in.Priority, + RefreshIntervalSec: in.RefreshIntervalSec, + CronExpr: in.CronExpr, + DefaultCommunityID: in.DefaultCommunityID, + DohProfileID: in.DohProfileID, } m.modules[id] = mod return mod, nil @@ -139,6 +139,7 @@ func (m *Memory) CreateCDNSource(tenantID, moduleID string, in *CDNSource) (*CDN ModuleID: moduleID, SourceKind: in.SourceKind, URL: strings.TrimSpace(in.URL), + PrefixPath: strings.TrimSpace(in.PrefixPath), Etag: in.Etag, RefreshIntervalSec: in.RefreshIntervalSec, CommunityID: in.CommunityID, @@ -166,6 +167,9 @@ func (m *Memory) UpdateCDNSource(tenantID, moduleID, sourceID string, patch *CDN if patch.URL != nil { s.URL = strings.TrimSpace(*patch.URL) } + if patch.PrefixPath != nil { + s.PrefixPath = strings.TrimSpace(*patch.PrefixPath) + } if patch.Etag != nil { s.Etag = *patch.Etag } @@ -654,7 +658,7 @@ func (m *Memory) CreatePeer(tenantID string, in *BGPPeer) (*BGPPeer, error) { p := &BGPPeer{ ID: id, TenantID: tenantID, SpeakerID: in.SpeakerID, Name: in.Name, Neighbor: neighbor, RemoteASN: in.RemoteASN, - Enabled: EffectivePeerEnabledOnCreate(in.Enabled, in.SessionState), + Enabled: EffectivePeerEnabledOnCreate(in.Enabled, in.SessionState), SessionState: in.SessionState, PoliciesJSON: in.PoliciesJSON, } m.peers[id] = p diff --git a/migrations/postgres/000006_cdn_source_json_path.down.sql b/migrations/postgres/000006_cdn_source_json_path.down.sql new file mode 100644 index 0000000..a453364 --- /dev/null +++ b/migrations/postgres/000006_cdn_source_json_path.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE module_cdn_source +DROP COLUMN prefix_path; diff --git a/migrations/postgres/000006_cdn_source_json_path.up.sql b/migrations/postgres/000006_cdn_source_json_path.up.sql new file mode 100644 index 0000000..354cc86 --- /dev/null +++ b/migrations/postgres/000006_cdn_source_json_path.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE module_cdn_source +ADD COLUMN prefix_path TEXT NOT NULL DEFAULT ''; diff --git a/migrations/sqlite/000006_cdn_source_json_path.down.sql b/migrations/sqlite/000006_cdn_source_json_path.down.sql new file mode 100644 index 0000000..a453364 --- /dev/null +++ b/migrations/sqlite/000006_cdn_source_json_path.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE module_cdn_source +DROP COLUMN prefix_path; diff --git a/migrations/sqlite/000006_cdn_source_json_path.up.sql b/migrations/sqlite/000006_cdn_source_json_path.up.sql new file mode 100644 index 0000000..354cc86 --- /dev/null +++ b/migrations/sqlite/000006_cdn_source_json_path.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE module_cdn_source +ADD COLUMN prefix_path TEXT NOT NULL DEFAULT ''; diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts index 76c6ed7..04723cf 100644 --- a/web/src/lib/api/types.ts +++ b/web/src/lib/api/types.ts @@ -60,17 +60,26 @@ export type CdnSource = { id: string; url: string; source_kind: string; + prefix_path: string; community_id: string | null; refresh_interval_sec: number | null; }; export type CdnSourceCreate = { url: string; source_kind: string; + prefix_path?: string; community_id?: string | null; }; export type CdnSourcePatch = Partial & { refresh_interval_sec?: number | null }; export type CdnSourcesResponse = Page; +export type CdnPreviewResponse = { + items: string[]; + total: number; + truncated: boolean; + source_url: string; +}; + // ---- Domain Entries ---- export type DomainEntry = { id: string; diff --git a/web/src/routes/modules/[moduleId]/+page.svelte b/web/src/routes/modules/[moduleId]/+page.svelte index 91be897..ed0a85c 100644 --- a/web/src/routes/modules/[moduleId]/+page.svelte +++ b/web/src/routes/modules/[moduleId]/+page.svelte @@ -14,6 +14,7 @@ CdnSource, CdnSourceCreate, CdnSourcesResponse, + CdnPreviewResponse, DomainEntry, DomainEntryCreate, DomainEntriesResponse, @@ -91,9 +92,20 @@ let cdnSources = $state([]); let cdnDialog = $state(false); let cdnEdit = $state(null); - let cdnForm = $state({ url: '', source_kind: '', community_id: null }); + let cdnForm = $state({ + url: '', + source_kind: 'plaintext', + prefix_path: '', + community_id: null + }); let cdnSaving = $state(false); let cdnDeleteTarget = $state(null); + let cdnPreviewLoading = $state(false); + let cdnPreviewItems = $state([]); + let cdnPreviewTotal = $state(0); + let cdnPreviewTruncated = $state(false); + let cdnPreviewError = $state(null); + let cdnPreviewOk = $state(false); // Domain entries let domainEntries = $state([]); @@ -164,6 +176,19 @@ onMount(loadMod); + function normalizeCdnSourceKind(k: string): 'plaintext' | 'json' { + return k.trim().toLowerCase() === 'json' ? 'json' : 'plaintext'; + } + + function clearCdnPreview() { + cdnPreviewLoading = false; + cdnPreviewItems = []; + cdnPreviewTotal = 0; + cdnPreviewTruncated = false; + cdnPreviewError = null; + cdnPreviewOk = false; + } + // --- Module Actions --- async function openEditMod() { if (!mod) return; @@ -270,24 +295,77 @@ // --- CDN Sources --- function openCdnCreate() { cdnEdit = null; - cdnForm = { url: '', source_kind: '', community_id: null }; + clearCdnPreview(); + cdnForm = { url: '', source_kind: 'plaintext', prefix_path: '', community_id: null }; cdnDialog = true; } function openCdnEdit(src: CdnSource) { cdnEdit = src; - cdnForm = { url: src.url, source_kind: src.source_kind, community_id: src.community_id, refresh_interval_sec: src.refresh_interval_sec }; + clearCdnPreview(); + cdnForm = { + url: src.url, + source_kind: normalizeCdnSourceKind(src.source_kind), + prefix_path: src.prefix_path ?? '', + community_id: src.community_id, + refresh_interval_sec: src.refresh_interval_sec + }; cdnDialog = true; } + async function previewCdn() { + const urlTrim = cdnForm.url.trim(); + if (!urlTrim) { + toast.error('Укажите URL'); + return; + } + cdnPreviewLoading = true; + cdnPreviewError = null; + cdnPreviewOk = false; + try { + const res = await apiMutate( + `/v1/modules/${moduleId}/cdn-sources/preview`, + 'POST', + { + url: urlTrim, + source_kind: cdnForm.source_kind, + prefix_path: cdnForm.prefix_path?.trim() ?? '' + } + ); + cdnPreviewItems = res.items; + cdnPreviewTotal = res.total; + cdnPreviewTruncated = res.truncated; + cdnPreviewOk = true; + } catch (e) { + cdnPreviewError = e instanceof Error ? e.message : String(e); + cdnPreviewItems = []; + cdnPreviewTotal = 0; + cdnPreviewTruncated = false; + cdnPreviewOk = false; + } finally { + cdnPreviewLoading = false; + } + } async function saveCdn() { + const urlTrim = cdnForm.url.trim(); + if (!urlTrim) { + toast.error('Укажите URL'); + return; + } cdnSaving = true; try { + const body = { + ...cdnForm, + url: urlTrim, + source_kind: cdnForm.source_kind, + prefix_path: cdnForm.prefix_path?.trim() ?? '' + }; if (cdnEdit) { - await apiMutate(`/v1/modules/${moduleId}/cdn-sources/${cdnEdit.id}`, 'PATCH', cdnForm); + await apiMutate(`/v1/modules/${moduleId}/cdn-sources/${cdnEdit.id}`, 'PATCH', body); toast.success('Источник обновлён'); } else { - await apiMutate(`/v1/modules/${moduleId}/cdn-sources`, 'POST', cdnForm); + await apiMutate(`/v1/modules/${moduleId}/cdn-sources`, 'POST', body); toast.success('Источник добавлен'); } + clearCdnPreview(); cdnDialog = false; await loadEntries(); } catch (e) { @@ -559,7 +637,16 @@ {#each cdnSources as src (src.id)} {src.url} - {src.source_kind} + +
+ {normalizeCdnSourceKind(src.source_kind)} + {#if src.prefix_path?.trim()} + {src.prefix_path} + {/if} +
+
{communityLabel(src.community_id)} {src.refresh_interval_sec ? `${src.refresh_interval_sec}с` : '—'} @@ -805,8 +892,13 @@ - - + { + if (!open) clearCdnPreview(); + }} +> + {cdnEdit ? 'Редактировать источник' : 'Новый CDN-источник'} @@ -817,7 +909,34 @@
- + +
+
+ + + {#if cdnForm.source_kind === 'json' && !cdnForm.prefix_path?.trim()} +

+ Для JSON укажите путь к полям с CIDR; пустой путь может не дать префиксов. +

+ {/if}
@@ -837,6 +956,28 @@
+
+
+ + {#if cdnPreviewError} + {cdnPreviewError} + {:else if cdnPreviewOk} + + Всего: {cdnPreviewTotal}{#if cdnPreviewTruncated} + (обрезано){/if} + + {/if} +
+ {#if cdnPreviewItems.length} +
    + {#each cdnPreviewItems as item, i (`${i}-${item}`)} +
  • {item}
  • + {/each} +
+ {/if} +