feat: enhance evobgp with new command-line tools for bundle management, including pull, verify, and apply functionalities. Update go.mod to include necessary dependencies and complete todos in architecture plan for improved observability and deployment practices.
CI / changes (push) Successful in 4s
CI / go (push) Failing after 6s
CI / bird2 (push) Has been skipped
CI / openapi (push) Has been skipped

This commit is contained in:
Denozordec committed 2026-04-05 14:07:45 +07:00
1 parent 272542b92a
commit bf52b21150
131 files changed
+9222 -17

No files matched your search

+114
View File
@@ -0,0 +1,114 @@
package birdfmt
import (
"fmt"
"strings"
)
// BGPPeerIPv4Options describes a single BGP session (BIRD 2, IPv4 AF).
type BGPPeerIPv4Options struct {
ProtocolName string // e.g. evobgp_peer_uplink
LocalIP string // e.g. 192.0.2.1
LocalASN uint32
NeighborIP string
NeighborASN uint32
// ExportFilter is a filter name, or empty for "export all".
ExportFilter string
// ImportFilter is a filter name, or empty for "import all".
ImportFilter string
}
// RenderProtocolBGPIPv4 renders a protocol bgp { … ipv4 { … } } block.
func RenderProtocolBGPIPv4(opts BGPPeerIPv4Options) (string, error) {
if strings.TrimSpace(opts.ProtocolName) == "" {
return "", fmt.Errorf("birdfmt: protocol name is required")
}
if strings.TrimSpace(opts.LocalIP) == "" || strings.TrimSpace(opts.NeighborIP) == "" {
return "", fmt.Errorf("birdfmt: local and neighbor addresses are required")
}
if opts.LocalASN == 0 || opts.NeighborASN == 0 {
return "", fmt.Errorf("birdfmt: AS numbers must be non-zero")
}
imp := "all"
if strings.TrimSpace(opts.ImportFilter) != "" {
imp = "filter " + strings.TrimSpace(opts.ImportFilter)
}
exp := "all"
if strings.TrimSpace(opts.ExportFilter) != "" {
exp = "filter " + strings.TrimSpace(opts.ExportFilter)
}
var b strings.Builder
b.WriteString("protocol bgp ")
b.WriteString(strings.TrimSpace(opts.ProtocolName))
b.WriteString(" {\n")
b.WriteString(" local ")
b.WriteString(strings.TrimSpace(opts.LocalIP))
fmt.Fprintf(&b, " as %d;\n", opts.LocalASN)
b.WriteString(" neighbor ")
b.WriteString(strings.TrimSpace(opts.NeighborIP))
fmt.Fprintf(&b, " as %d;\n", opts.NeighborASN)
b.WriteString(" ipv4 {\n")
b.WriteString(" import ")
b.WriteString(imp)
b.WriteString(";\n")
b.WriteString(" export ")
b.WriteString(exp)
b.WriteString(";\n")
b.WriteString(" };\n")
b.WriteString("}\n")
return b.String(), nil
}
// BGPPeerIPv6Options describes a BGP session for the IPv6 AF.
type BGPPeerIPv6Options struct {
ProtocolName string
LocalIP string
LocalASN uint32
NeighborIP string
NeighborASN uint32
ExportFilter string
ImportFilter string
}
// RenderProtocolBGPIPv6 renders a protocol bgp block with ipv6 { import/export }.
func RenderProtocolBGPIPv6(opts BGPPeerIPv6Options) (string, error) {
if strings.TrimSpace(opts.ProtocolName) == "" {
return "", fmt.Errorf("birdfmt: protocol name is required")
}
if strings.TrimSpace(opts.LocalIP) == "" || strings.TrimSpace(opts.NeighborIP) == "" {
return "", fmt.Errorf("birdfmt: local and neighbor addresses are required")
}
if opts.LocalASN == 0 || opts.NeighborASN == 0 {
return "", fmt.Errorf("birdfmt: AS numbers must be non-zero")
}
imp := "all"
if strings.TrimSpace(opts.ImportFilter) != "" {
imp = "filter " + strings.TrimSpace(opts.ImportFilter)
}
exp := "all"
if strings.TrimSpace(opts.ExportFilter) != "" {
exp = "filter " + strings.TrimSpace(opts.ExportFilter)
}
var b strings.Builder
b.WriteString("protocol bgp ")
b.WriteString(strings.TrimSpace(opts.ProtocolName))
b.WriteString(" {\n")
b.WriteString(" local ")
b.WriteString(strings.TrimSpace(opts.LocalIP))
fmt.Fprintf(&b, " as %d;\n", opts.LocalASN)
b.WriteString(" neighbor ")
b.WriteString(strings.TrimSpace(opts.NeighborIP))
fmt.Fprintf(&b, " as %d;\n", opts.NeighborASN)
b.WriteString(" ipv6 {\n")
b.WriteString(" import ")
b.WriteString(imp)
b.WriteString(";\n")
b.WriteString(" export ")
b.WriteString(exp)
b.WriteString(";\n")
b.WriteString(" };\n")
b.WriteString("}\n")
return b.String(), nil
}
+29
View File
@@ -0,0 +1,29 @@
package birdfmt
import (
"strings"
"testing"
)
func TestRenderProtocolBGPIPv4_Validation(t *testing.T) {
_, err := RenderProtocolBGPIPv4(BGPPeerIPv4Options{})
if err == nil {
t.Fatal("expected error")
}
}
func TestRenderProtocolBGPIPv4_ExportAll(t *testing.T) {
got, err := RenderProtocolBGPIPv4(BGPPeerIPv4Options{
ProtocolName: "p",
LocalIP: "192.0.2.1",
LocalASN: 1,
NeighborIP: "192.0.2.2",
NeighborASN: 2,
})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(got, "export all;") {
t.Fatal(got)
}
}
+71
View File
@@ -0,0 +1,71 @@
package birdfmt
import (
"bytes"
"context"
"fmt"
"os/exec"
"strings"
)
// BirdCtl runs bird(8) and birdc(8) for parse checks and configure reload.
type BirdCtl struct {
// Bird is the bird binary path (default "bird").
Bird string
// Birdc is the birdc binary path (default "birdc").
Birdc string
// Socket is optional birdc control socket (-s); empty uses birdc default.
Socket string
}
func (c *BirdCtl) birdBin() string {
if strings.TrimSpace(c.Bird) != "" {
return c.Bird
}
return "bird"
}
func (c *BirdCtl) birdcBin() string {
if strings.TrimSpace(c.Birdc) != "" {
return c.Birdc
}
return "birdc"
}
// ParseCheck runs `bird -c <mainConfigPath> -p` to validate syntax without starting the daemon.
func (c *BirdCtl) ParseCheck(ctx context.Context, mainConfigPath string) error {
mainConfigPath = strings.TrimSpace(mainConfigPath)
if mainConfigPath == "" {
return fmt.Errorf("birdfmt: main config path is required for parse check")
}
cmd := exec.CommandContext(ctx, c.birdBin(), "-c", mainConfigPath, "-p")
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(stderr.String())
if msg != "" {
return fmt.Errorf("bird -p: %w: %s", err, msg)
}
return fmt.Errorf("bird -p: %w", err)
}
return nil
}
// Configure runs `birdc configure` to load the current config from disk (BIRD 2).
func (c *BirdCtl) Configure(ctx context.Context) error {
args := []string{"configure"}
if s := strings.TrimSpace(c.Socket); s != "" {
args = append([]string{"-s", s}, args...)
}
cmd := exec.CommandContext(ctx, c.birdcBin(), args...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(stderr.String())
if msg != "" {
return fmt.Errorf("birdc configure: %w: %s", err, msg)
}
return fmt.Errorf("birdc configure: %w", err)
}
return nil
}
+19
View File
@@ -0,0 +1,19 @@
// Package birdfmt builds BIRD 2 configuration text: main bird.conf skeleton, include
// fragments under bird.d/, export filters, static route protocols, and minimal BGP peer
// blocks. Runtime helpers parse-check configs (bird -p) and reload (birdc configure).
//
// # Layout
//
// Operator keeps a stable bird.conf (or generated skeleton) next to EvoBGP fragments:
//
// bird.conf — router id, protocol device, protocol direct, include lines
// bird.d/evobgp_*.conf — generated prefixes, filters, peers (names from constants)
//
// Include order should list filter definitions before protocols that reference them
// (see StandardIncludeFragments).
//
// # Apply workflow
//
// 1. Write new fragment files to a staging directory, run bird -c <bird.conf> -p.
// 2. Atomically swap staging → live config dir, then birdc configure (see BirdCtl).
package birdfmt
+79
View File
@@ -0,0 +1,79 @@
package birdfmt
import (
"fmt"
"net/netip"
"sort"
"strings"
)
// RenderExportFilterIPv4 renders a BIRD 2 filter that accepts IPv4 routes whose prefix
// is in prefixes (exact CIDR match via set membership), and rejects others.
func RenderExportFilterIPv4(filterName string, prefixes []netip.Prefix) (string, error) {
if strings.TrimSpace(filterName) == "" {
return "", fmt.Errorf("birdfmt: filter name is required")
}
uniq := make(map[string]netip.Prefix)
for _, p := range prefixes {
if !p.Addr().Is4() {
continue
}
m := p.Masked()
uniq[m.String()] = m
}
keys := make([]string, 0, len(uniq))
for k := range uniq {
keys = append(keys, k)
}
sort.Strings(keys)
var b strings.Builder
b.WriteString("filter ")
b.WriteString(strings.TrimSpace(filterName))
b.WriteString(" {\n")
if len(keys) == 0 {
b.WriteString(" reject;\n")
} else {
b.WriteString(" if net ~ [ ")
b.WriteString(strings.Join(keys, ", "))
b.WriteString(" ] then accept;\n")
b.WriteString(" reject;\n")
}
b.WriteString("}\n")
return b.String(), nil
}
// RenderExportFilterIPv6 renders a BIRD 2 filter for IPv6 prefixes (CIDR set, then reject).
func RenderExportFilterIPv6(filterName string, prefixes []netip.Prefix) (string, error) {
if strings.TrimSpace(filterName) == "" {
return "", fmt.Errorf("birdfmt: filter name is required")
}
uniq := make(map[string]netip.Prefix)
for _, p := range prefixes {
if !p.Addr().Is6() {
continue
}
m := p.Masked()
uniq[m.String()] = m
}
keys := make([]string, 0, len(uniq))
for k := range uniq {
keys = append(keys, k)
}
sort.Strings(keys)
var b strings.Builder
b.WriteString("filter ")
b.WriteString(strings.TrimSpace(filterName))
b.WriteString(" {\n")
if len(keys) == 0 {
b.WriteString(" reject;\n")
} else {
b.WriteString(" if net ~ [ ")
b.WriteString(strings.Join(keys, ", "))
b.WriteString(" ] then accept;\n")
b.WriteString(" reject;\n")
}
b.WriteString("}\n")
return b.String(), nil
}
+47
View File
@@ -0,0 +1,47 @@
package birdfmt
import (
"net/netip"
"os"
"strings"
"testing"
)
func TestRenderExportFilterIPv4_Empty(t *testing.T) {
got, err := RenderExportFilterIPv4("evobgp_x", nil)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(got, "filter evobgp_x") {
t.Fatal(got)
}
if !strings.Contains(got, "reject;") {
t.Fatal(got)
}
}
func TestRenderExportFilterIPv4_SkipsNonV4(t *testing.T) {
v6 := netip.MustParsePrefix("2001:db8::/32")
got, err := RenderExportFilterIPv4("f", []netip.Prefix{v6})
if err != nil {
t.Fatal(err)
}
if strings.Contains(got, "2001:db8") {
t.Fatal("v6 prefix should be skipped in v4 filter")
}
}
func TestRenderExportFilterIPv6(t *testing.T) {
p := netip.MustParsePrefix("2001:db8::/32")
got, err := RenderExportFilterIPv6("evobgp_export_v6", []netip.Prefix{p, p})
if err != nil {
t.Fatal(err)
}
want, err := os.ReadFile("testdata/scenarios/standard_layout/bird.d/evobgp_filters_v6.conf")
if err != nil {
t.Fatal(err)
}
if strings.TrimSpace(got) != strings.TrimSpace(string(want)) {
t.Fatalf("mismatch\n--- got ---\n%s\n--- want ---\n%s", got, string(want))
}
}
+47
View File
@@ -0,0 +1,47 @@
package birdfmt
import (
"fmt"
"strings"
)
// ManagedBanner returns a comment block marking EvoBGP-generated files (optional first line in bird.d/*.conf).
func ManagedBanner(revisionHint string) string {
rev := strings.TrimSpace(revisionHint)
var b strings.Builder
b.WriteString("# EvoBGP generated — do not edit by hand.\n")
if rev != "" {
b.WriteString("# Revision: ")
b.WriteString(rev)
b.WriteByte('\n')
}
return b.String()
}
// JoinFragments concatenates non-empty text blocks with a blank line between them.
func JoinFragments(parts ...string) string {
var out []string
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
out = append(out, p)
}
if len(out) == 0 {
return ""
}
return strings.Join(out, "\n\n") + "\n"
}
// ValidateIncludePath rejects paths that could break out of the config directory.
func ValidateIncludePath(p string) error {
p = strings.TrimSpace(p)
if p == "" {
return fmt.Errorf("birdfmt: include path is empty")
}
if strings.Contains(p, "..") {
return fmt.Errorf("birdfmt: include path must not contain '..': %q", p)
}
return nil
}
+22
View File
@@ -0,0 +1,22 @@
package birdfmt
import "testing"
func TestValidateIncludePath(t *testing.T) {
if err := ValidateIncludePath("bird.d/x.conf"); err != nil {
t.Fatal(err)
}
if err := ValidateIncludePath("../etc/passwd"); err == nil {
t.Fatal("expected error for ..")
}
}
func TestJoinFragments(t *testing.T) {
got := JoinFragments("a", "", "b")
if got != "a\n\nb\n" {
t.Fatalf("%q", got)
}
if JoinFragments() != "" {
t.Fatal("empty join should be empty")
}
}
+95
View File
@@ -0,0 +1,95 @@
package birdfmt
import (
"fmt"
"strings"
)
// Directory and fragment file names for EvoBGP-generated includes (relative to bird.conf).
const (
DirBirdD = "bird.d"
FragmentPrefixesV4 = "evobgp_prefixes_v4.conf"
FragmentPrefixesV6 = "evobgp_prefixes_v6.conf"
FragmentFiltersV4 = "evobgp_filters_v4.conf"
FragmentFiltersV6 = "evobgp_filters_v6.conf"
FragmentPeers = "evobgp_peers.conf"
)
// FragmentIncludePath returns a POSIX include path such as bird.d/evobgp_prefixes_v4.conf.
func FragmentIncludePath(fragmentBaseName string) string {
if fragmentBaseName == "" {
return DirBirdD + "/"
}
return DirBirdD + "/" + fragmentBaseName
}
// StandardIncludeFragments is the recommended order: filters before peers that reference them.
func StandardIncludeFragments() []string {
return []string{
FragmentIncludePath(FragmentFiltersV4),
FragmentIncludePath(FragmentFiltersV6),
FragmentIncludePath(FragmentPrefixesV4),
FragmentIncludePath(FragmentPrefixesV6),
FragmentIncludePath(FragmentPeers),
}
}
// MainBirdConfOptions describes the top-level bird.conf skeleton EvoBGP expects beside bird.d/.
type MainBirdConfOptions struct {
// RouterID is the BIRD router id (IPv4 dotted quad recommended).
RouterID string
// Includes are paths as in include "…" (e.g. bird.d/evobgp_prefixes_v4.conf).
Includes []string
// Preamble is optional comment lines (each line prefixed with #), no trailing newline required.
Preamble string
}
// RenderMainBirdConf returns a BIRD 2 main config: device, direct, include lines.
// RouterID must be non-empty.
func RenderMainBirdConf(opts MainBirdConfOptions) (string, error) {
if strings.TrimSpace(opts.RouterID) == "" {
return "", fmt.Errorf("birdfmt: router id is required")
}
var b strings.Builder
pre := strings.TrimSpace(opts.Preamble)
if pre != "" {
for _, line := range strings.Split(pre, "\n") {
line = strings.TrimRight(line, "\r")
if line == "" {
b.WriteByte('\n')
continue
}
if !strings.HasPrefix(line, "#") {
b.WriteString("# ")
}
b.WriteString(line)
b.WriteByte('\n')
}
b.WriteByte('\n')
}
b.WriteString("router id ")
b.WriteString(strings.TrimSpace(opts.RouterID))
b.WriteString(";\n\n")
for _, inc := range opts.Includes {
inc = strings.TrimSpace(inc)
if inc == "" {
continue
}
b.WriteString("include \"")
b.WriteString(inc)
b.WriteString("\";\n")
}
if len(opts.Includes) > 0 {
b.WriteByte('\n')
}
b.WriteString(`protocol device {
}
protocol direct {
ipv4;
ipv6;
}
`)
return b.String(), nil
}
+55
View File
@@ -0,0 +1,55 @@
package birdfmt
import (
_ "embed"
"strings"
"testing"
)
//go:embed testdata/golden/main_bird_skeleton.golden
var goldenMainBirdSkeleton string
func TestRenderMainBirdConf_Golden(t *testing.T) {
got, err := RenderMainBirdConf(MainBirdConfOptions{
RouterID: "192.0.2.1",
Includes: StandardIncludeFragments(),
})
if err != nil {
t.Fatal(err)
}
want := strings.TrimSuffix(goldenMainBirdSkeleton, "\n")
got = strings.TrimSuffix(got, "\n")
if got != want {
t.Fatalf("golden mismatch\n--- got ---\n%s\n--- want ---\n%s", got, want)
}
}
func TestRenderMainBirdConf_Preamble(t *testing.T) {
got, err := RenderMainBirdConf(MainBirdConfOptions{
RouterID: "192.0.2.1",
Includes: nil,
Preamble: "operator note\n# already commented",
})
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(got, "# operator note\n") {
t.Fatalf("expected preamble prefix, got:\n%s", got)
}
if !strings.Contains(got, "# # already commented") {
t.Fatal(got)
}
}
func TestRenderMainBirdConf_Errors(t *testing.T) {
_, err := RenderMainBirdConf(MainBirdConfOptions{})
if err == nil {
t.Fatal("expected error for empty router id")
}
}
func TestFragmentIncludePath(t *testing.T) {
if p := FragmentIncludePath(FragmentPrefixesV4); p != "bird.d/evobgp_prefixes_v4.conf" {
t.Fatal(p)
}
}
+52
View File
@@ -0,0 +1,52 @@
package birdfmt
import (
"bytes"
"context"
"fmt"
"os/exec"
"strings"
)
// ShowProtocols runs `birdc [-s socket] show protocols all` and returns stdout (BIRD 2).
func ShowProtocols(ctx context.Context, socket, birdcBin string) (string, error) {
if birdcBin == "" {
birdcBin = "birdc"
}
args := []string{"show", "protocols", "all"}
if s := strings.TrimSpace(socket); s != "" {
args = append([]string{"-s", s}, args...)
}
cmd := exec.CommandContext(ctx, birdcBin, args...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(stderr.String())
if msg != "" {
return "", fmt.Errorf("birdc show protocols: %w: %s", err, msg)
}
return "", fmt.Errorf("birdc show protocols: %w", err)
}
return stdout.String(), nil
}
// CountEstablishedBGPSessions counts BGP protocol rows whose line contains "Established"
// (heuristic for `birdc show protocols` / `show protocols all` output).
func CountEstablishedBGPSessions(showProtocolsOutput string) int {
lines := strings.Split(showProtocolsOutput, "\n")
n := 0
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "name") || strings.HasPrefix(strings.ToLower(line), "table") {
continue
}
if !strings.Contains(strings.ToLower(line), "bgp") {
continue
}
if strings.Contains(strings.ToLower(line), "established") {
n++
}
}
return n
}
+16
View File
@@ -0,0 +1,16 @@
package birdfmt
import "testing"
func TestCountEstablishedBGPSessions(t *testing.T) {
sample := `
BIRD 2.14 ready.
Name Proto Table State Since Info
device1 Device --- up 10:00:00
uplink4 BGP --- up 10:00:05 Established
uplink6 BGP --- start 10:00:06 Active
`
if got := CountEstablishedBGPSessions(sample); got != 1 {
t.Fatalf("got %d want 1", got)
}
}
+1
View File
@@ -23,6 +23,7 @@ func TestBirdScenarioPaths_Table(t *testing.T) {
"large_prefix_list",
"minimal",
"mixed_static_bgp",
"standard_layout",
"static_ipv4",
"with_include",
}
+72
View File
@@ -0,0 +1,72 @@
package birdfmt
import (
"net/netip"
"os"
"strings"
"testing"
)
// Ensures generator output matches the standard_layout scenario files (CI bird -p).
func TestStandardLayout_GeneratorMatchesFixtures(t *testing.T) {
p4 := netip.MustParsePrefix("203.0.113.0/24")
f4, err := RenderExportFilterIPv4("evobgp_export_v4", []netip.Prefix{p4})
if err != nil {
t.Fatal(err)
}
assertFileEquals(t, "testdata/scenarios/standard_layout/bird.d/evobgp_filters_v4.conf", f4)
f6, err := RenderExportFilterIPv6("evobgp_export_v6", nil)
if err != nil {
t.Fatal(err)
}
assertFileEquals(t, "testdata/scenarios/standard_layout/bird.d/evobgp_filters_v6.conf", f6)
staticV4 := RenderStaticIPv4Protocol("evobgp_prefixes_v4", []netip.Prefix{p4})
assertFileEquals(t, "testdata/scenarios/standard_layout/bird.d/evobgp_prefixes_v4.conf", staticV4)
staticV6 := RenderStaticIPv6Protocol("evobgp_prefixes_v6", nil)
assertFileEquals(t, "testdata/scenarios/standard_layout/bird.d/evobgp_prefixes_v6.conf", staticV6)
peer, err := RenderProtocolBGPIPv4(BGPPeerIPv4Options{
ProtocolName: "evobgp_peer_ci",
LocalIP: "192.0.2.1",
LocalASN: 65001,
NeighborIP: "192.0.2.2",
NeighborASN: 65002,
ExportFilter: "evobgp_export_v4",
})
if err != nil {
t.Fatal(err)
}
assertFileEquals(t, "testdata/scenarios/standard_layout/bird.d/evobgp_peers.conf", peer)
main, err := RenderMainBirdConf(MainBirdConfOptions{
RouterID: "192.0.2.1",
Includes: StandardIncludeFragments(),
Preamble: "tags: layout, include, filter, peer\nStandard EvoBGP layout: main skeleton + bird.d fragments (matches StandardIncludeFragments).",
})
if err != nil {
t.Fatal(err)
}
// Fixture has two header comment lines; RenderMainBirdConf adds # per line.
wantMain, err := os.ReadFile("testdata/scenarios/standard_layout/bird.conf")
if err != nil {
t.Fatal(err)
}
if strings.TrimSpace(main) != strings.TrimSpace(string(wantMain)) {
t.Fatalf("main bird.conf mismatch\n--- got ---\n%s\n--- want ---\n%s", main, wantMain)
}
}
func assertFileEquals(t *testing.T, path, content string) {
t.Helper()
want, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if strings.TrimSpace(content) != strings.TrimSpace(string(want)) {
t.Fatalf("%s mismatch\n--- got ---\n%s\n--- want ---\n%s", path, content, string(want))
}
}
+31
View File
@@ -37,3 +37,34 @@ func RenderStaticIPv4Protocol(protocolName string, prefixes []netip.Prefix) stri
b.WriteString("}\n")
return b.String()
}
// RenderStaticIPv6Protocol renders a BIRD 2 `protocol static` block for IPv6 prefixes.
func RenderStaticIPv6Protocol(protocolName string, prefixes []netip.Prefix) string {
if protocolName == "" {
protocolName = "evobgp_static_v6"
}
uniq := make(map[string]netip.Prefix)
for _, p := range prefixes {
if !p.Addr().Is6() {
continue
}
uniq[p.String()] = p.Masked()
}
keys := make([]string, 0, len(uniq))
for k := range uniq {
keys = append(keys, k)
}
sort.Strings(keys)
var b strings.Builder
b.WriteString("protocol static ")
b.WriteString(protocolName)
b.WriteString(" {\n ipv6;\n")
for _, k := range keys {
b.WriteString(" route ")
b.WriteString(k)
b.WriteString(" unreachable;\n")
}
b.WriteString("}\n")
return b.String()
}
+11
View File
@@ -32,3 +32,14 @@ func TestRenderStaticIPv4Protocol_Empty(t *testing.T) {
t.Fatal(got)
}
}
func TestRenderStaticIPv6Protocol_Dedup(t *testing.T) {
p := netip.MustParsePrefix("2001:db8::/32")
got := RenderStaticIPv6Protocol("evobgp_v6", []netip.Prefix{p, p})
if !strings.Contains(got, "2001:db8::/32") {
t.Fatal(got)
}
if !strings.Contains(got, "ipv6") {
t.Fatal(got)
}
}
@@ -0,0 +1,15 @@
router id 192.0.2.1;
include "bird.d/evobgp_filters_v4.conf";
include "bird.d/evobgp_filters_v6.conf";
include "bird.d/evobgp_prefixes_v4.conf";
include "bird.d/evobgp_prefixes_v6.conf";
include "bird.d/evobgp_peers.conf";
protocol device {
}
protocol direct {
ipv4;
ipv6;
}
@@ -0,0 +1,18 @@
# tags: layout, include, filter, peer
# Standard EvoBGP layout: main skeleton + bird.d fragments (matches StandardIncludeFragments).
router id 192.0.2.1;
include "bird.d/evobgp_filters_v4.conf";
include "bird.d/evobgp_filters_v6.conf";
include "bird.d/evobgp_prefixes_v4.conf";
include "bird.d/evobgp_prefixes_v6.conf";
include "bird.d/evobgp_peers.conf";
protocol device {
}
protocol direct {
ipv4;
ipv6;
}
@@ -0,0 +1,4 @@
filter evobgp_export_v4 {
if net ~ [ 203.0.113.0/24 ] then accept;
reject;
}
@@ -0,0 +1,3 @@
filter evobgp_export_v6 {
reject;
}
@@ -0,0 +1,8 @@
protocol bgp evobgp_peer_ci {
local 192.0.2.1 as 65001;
neighbor 192.0.2.2 as 65002;
ipv4 {
import all;
export filter evobgp_export_v4;
};
}
@@ -0,0 +1,4 @@
protocol static evobgp_prefixes_v4 {
ipv4;
route 203.0.113.0/24 unreachable;
}
@@ -0,0 +1,3 @@
protocol static evobgp_prefixes_v6 {
ipv6;
}
+51
View File
@@ -0,0 +1,51 @@
package bundle
import (
"crypto/ed25519"
"crypto/rand"
"testing"
)
func TestBuildVerifyRoundtrip(t *testing.T) {
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
frag := map[string]string{
"bird.conf": "router id 192.0.2.1;\n\nprotocol device {\n}\n\nprotocol direct {\n ipv4;\n ipv6;\n}\n",
}
raw, err := BuildGzippedTar("rev-1", "sp-1", frag, priv)
if err != nil {
t.Fatal(err)
}
pub := priv.Public().(ed25519.PublicKey)
v, err := VerifyGzippedTar(raw, pub)
if err != nil {
t.Fatal(err)
}
if v.Manifest.RevisionID != "rev-1" {
t.Fatalf("revision %q", v.Manifest.RevisionID)
}
if len(v.Files) != 1 {
t.Fatalf("files %d", len(v.Files))
}
}
func TestVerifyWrongKey(t *testing.T) {
_, privA, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
_, privB, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
raw, err := BuildGzippedTar("rev-x", "sp-x", map[string]string{"a.conf": "x"}, privA)
if err != nil {
t.Fatal(err)
}
pubB := privB.Public().(ed25519.PublicKey)
if _, err := VerifyGzippedTar(raw, pubB); err == nil {
t.Fatal("expected signature error with wrong public key")
}
}
+46
View File
@@ -0,0 +1,46 @@
package bundle
import (
"fmt"
"os"
pathpkg "path"
"path/filepath"
"strings"
)
// WriteExtractedFiles writes payload files from a verified bundle under root (directories created as needed).
func WriteExtractedFiles(root string, v *VerifiedContents) error {
root = filepath.Clean(root)
for _, fe := range v.Manifest.Files {
data, ok := v.Files[fe.Path]
if !ok {
return fmt.Errorf("bundle: missing file %q", fe.Path)
}
rel := pathpkg.Clean(strings.TrimPrefix(fe.Path, "/"))
if rel == "." || strings.HasPrefix(rel, "..") {
return fmt.Errorf("bundle: unsafe path %q", fe.Path)
}
dest := filepath.Join(root, filepath.FromSlash(rel))
relToRoot, err := filepath.Rel(root, dest)
if err != nil || strings.HasPrefix(relToRoot, "..") {
return fmt.Errorf("bundle: path escapes root: %q", fe.Path)
}
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return err
}
if err := os.WriteFile(dest, data, 0o644); err != nil {
return err
}
}
return nil
}
// FindMainBirdConf returns the first path ending with bird.conf from extracted manifest order.
func (v *VerifiedContents) FindMainBirdConf() string {
for _, fe := range v.Manifest.Files {
if strings.HasSuffix(fe.Path, "bird.conf") {
return fe.Path
}
}
return ""
}
+131
View File
@@ -0,0 +1,131 @@
package bundle
import (
"archive/tar"
"bytes"
"compress/gzip"
"crypto/ed25519"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"path"
"sort"
"strings"
"time"
)
// Manifest describes bundle contents for evobgp-node verification.
type Manifest struct {
RevisionID string `json:"revision_id"`
SpeakerID string `json:"speaker_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
Files []FileEntry `json:"files"`
Algorithm string `json:"signature_algorithm"`
PublicKeyB64 string `json:"public_key_base64"`
}
// FileEntry is one file inside the bundle archive.
type FileEntry struct {
Path string `json:"path"`
SHA256 string `json:"sha256"`
}
const sigFileName = "bundle.sig"
const manifestName = "manifest.json"
// BuildGzippedTar builds a .tar.gz with manifest.json, bundle.sig (Ed25519 over manifest JSON), and payload files.
func BuildGzippedTar(revisionID, speakerID string, fragments map[string]string, priv ed25519.PrivateKey) ([]byte, error) {
if len(priv) != ed25519.PrivateKeySize {
return nil, fmt.Errorf("bundle: invalid ed25519 private key size")
}
pub := priv.Public().(ed25519.PublicKey)
norm := make(map[string]string, len(fragments))
for p, content := range fragments {
p = path.Clean(strings.TrimPrefix(p, "/"))
if p == "." || strings.HasPrefix(p, "..") {
return nil, fmt.Errorf("bundle: invalid path %q", p)
}
norm[p] = content
}
var files []FileEntry
var payload [][]byte
keys := sortedStringKeys(norm)
for _, p := range keys {
data := []byte(norm[p])
h := sha256.Sum256(data)
files = append(files, FileEntry{Path: p, SHA256: fmt.Sprintf("%x", h[:])})
payload = append(payload, data)
}
m := Manifest{
RevisionID: revisionID,
SpeakerID: speakerID,
CreatedAt: time.Now().UTC(),
Files: files,
Algorithm: "ed25519",
PublicKeyB64: base64.StdEncoding.EncodeToString(pub),
}
manifestJSON, err := json.MarshalIndent(m, "", " ")
if err != nil {
return nil, err
}
sig := ed25519.Sign(priv, manifestJSON)
var buf bytes.Buffer
gw := gzip.NewWriter(&buf)
tw := tar.NewWriter(gw)
add := func(name string, body []byte) error {
hdr := &tar.Header{
Name: name,
Mode: 0o644,
Size: int64(len(body)),
ModTime: time.Now().UTC(),
}
if err := tw.WriteHeader(hdr); err != nil {
return err
}
_, err := tw.Write(body)
return err
}
if err := add(manifestName, manifestJSON); err != nil {
_ = tw.Close()
_ = gw.Close()
return nil, err
}
if err := add(sigFileName, sig); err != nil {
_ = tw.Close()
_ = gw.Close()
return nil, err
}
i := 0
for _, p := range keys {
if err := add(p, payload[i]); err != nil {
_ = tw.Close()
_ = gw.Close()
return nil, err
}
i++
}
if err := tw.Close(); err != nil {
_ = gw.Close()
return nil, err
}
if err := gw.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func sortedStringKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
+110
View File
@@ -0,0 +1,110 @@
package bundle
import (
"archive/tar"
"bytes"
"compress/gzip"
"crypto/ed25519"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"path"
"strings"
)
// VerifiedContents is the result of verifying a downloaded bundle on evobgp-node.
type VerifiedContents struct {
Manifest Manifest
Files map[string][]byte
}
// VerifyGzippedTar checks Ed25519 signature over manifest.json and SHA-256 of each file.
func VerifyGzippedTar(bundle []byte, pub ed25519.PublicKey) (*VerifiedContents, error) {
gr, err := gzip.NewReader(bytes.NewReader(bundle))
if err != nil {
return nil, err
}
defer gr.Close()
var manifestRaw []byte
var sig []byte
files := make(map[string][]byte)
tr := tar.NewReader(gr)
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return nil, err
}
name := path.Clean(hdr.Name)
if strings.HasPrefix(name, "..") {
return nil, fmt.Errorf("bundle: illegal tar entry %q", hdr.Name)
}
data, err := io.ReadAll(tr)
if err != nil {
return nil, err
}
switch name {
case manifestName:
manifestRaw = data
case sigFileName:
sig = data
default:
files[name] = data
}
}
if len(manifestRaw) == 0 || len(sig) == 0 {
return nil, fmt.Errorf("bundle: missing manifest or signature")
}
if !ed25519.Verify(pub, manifestRaw, sig) {
return nil, fmt.Errorf("bundle: ed25519 signature mismatch")
}
var m Manifest
if err := json.Unmarshal(manifestRaw, &m); err != nil {
return nil, err
}
for _, fe := range m.Files {
body, ok := files[fe.Path]
if !ok {
return nil, fmt.Errorf("bundle: missing file %q", fe.Path)
}
h := sha256.Sum256(body)
if fmt.Sprintf("%x", h[:]) != fe.SHA256 {
return nil, fmt.Errorf("bundle: checksum mismatch for %q", fe.Path)
}
}
return &VerifiedContents{Manifest: m, Files: files}, nil
}
// ParsePublicKeyHex decodes a 64-char hex Ed25519 public key (32 bytes).
func ParsePublicKeyHex(s string) (ed25519.PublicKey, error) {
s = strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(s, "0x"), "0X"))
raw, err := hex.DecodeString(s)
if err != nil {
return nil, fmt.Errorf("bundle: hex decode: %w", err)
}
if len(raw) != ed25519.PublicKeySize {
return nil, fmt.Errorf("bundle: want %d-byte ed25519 public key", ed25519.PublicKeySize)
}
return ed25519.PublicKey(raw), nil
}
// PublicKeyFromManifestBase64 uses the key embedded in the signed manifest (cross-check with expected pubkey optional).
func PublicKeyFromManifestBase64(m *Manifest) (ed25519.PublicKey, error) {
if m.PublicKeyB64 == "" {
return nil, fmt.Errorf("bundle: manifest missing public_key_base64")
}
raw, err := base64.StdEncoding.DecodeString(m.PublicKeyB64)
if err != nil {
return nil, err
}
if len(raw) != ed25519.PublicKeySize {
return nil, fmt.Errorf("bundle: bad public key length")
}
return ed25519.PublicKey(raw), nil
}
+35
View File
@@ -0,0 +1,35 @@
// Package config loads control-plane settings from the environment (see architecture plan §1).
package config
import (
"os"
"strings"
)
// Env holds shared process configuration for cmd/* entrypoints.
type Env struct {
HTTPAddr string
ServiceName string
GitSHA string
// DatabaseURL is the PostgreSQL DSN when SQL-backed repositories are wired (empty in dev).
DatabaseURL string
// BrokerURL is NATS/Redis when the reference profile uses a message broker (empty in microVPS).
BrokerURL string
}
// Load reads EVOBGP_* environment variables with safe defaults.
func Load() Env {
e := Env{
HTTPAddr: ":8080",
}
if v := strings.TrimSpace(os.Getenv("EVOBGP_HTTP_ADDR")); v != "" {
e.HTTPAddr = v
}
if v := strings.TrimSpace(os.Getenv("EVOBGP_SERVICE")); v != "" {
e.ServiceName = v
}
e.GitSHA = strings.TrimSpace(os.Getenv("EVOBGP_GIT_SHA"))
e.DatabaseURL = strings.TrimSpace(os.Getenv("EVOBGP_DATABASE_URL"))
e.BrokerURL = strings.TrimSpace(os.Getenv("EVOBGP_BROKER_URL"))
return e
}
+9
View File
@@ -0,0 +1,9 @@
package db
import "evobgp/migrations"
// PostgresMigrations and SQLiteMigrations expose embedded SQL files for migrate runners (plan §1, §11).
var (
PostgresMigrations = migrations.Postgres
SQLiteMigrations = migrations.SQLite
)
+23
View File
@@ -0,0 +1,23 @@
// Package deploy delivers generated BIRD fragments to evobgp-agent / publishes signed bundles for evobgp-node.
package deploy
import (
"context"
"log"
"time"
)
// Run blocks until ctx is cancelled. Reference deployment: worker after render, talks to agent API or shared volume.
func Run(ctx context.Context) {
t := time.NewTicker(60 * time.Second)
defer t.Stop()
log.Printf("evobgp-deploy: started (stub; deploy jobs push configs and update bundle pointers)")
for {
select {
case <-ctx.Done():
log.Printf("evobgp-deploy: stopped")
return
case <-t.C:
}
}
}
+10
View File
@@ -0,0 +1,10 @@
// Package domain holds pure types and invariants without I/O (plan §1).
package domain
// Module kinds for prefix sources (plan §6, §7.3).
const (
ModuleASPrefixes = "AS_PREFIXES"
ModuleCDNCIDRs = "CDN_CIDRS"
ModuleDomains = "DOMAINS"
ModuleIPRanges = "IP_RANGES"
)
+135
View File
@@ -0,0 +1,135 @@
package httpapi
import (
"context"
"net/http"
"strings"
)
type ctxKey int
const authCtxKey ctxKey = 1
// Auth holds resolved API identity for a request.
type Auth struct {
TenantID string
Role string // viewer, editor, operator, node
Token string
}
func authFromContext(ctx context.Context) (Auth, bool) {
a, ok := ctx.Value(authCtxKey).(Auth)
return a, ok
}
type apiKeyRecord struct {
token string
tenantID string
role string
}
func parseAPIKeysSpec(spec string) []apiKeyRecord {
spec = strings.TrimSpace(spec)
if spec == "" {
return nil
}
var out []apiKeyRecord
for _, part := range strings.Split(spec, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
fields := strings.Split(part, "|")
if len(fields) != 3 {
continue
}
out = append(out, apiKeyRecord{
token: strings.TrimSpace(fields[0]),
tenantID: strings.TrimSpace(fields[1]),
role: strings.TrimSpace(fields[2]),
})
}
return out
}
func (s *Server) authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.insecureDev {
h := r.Header.Get("Authorization")
const p = "Bearer "
if strings.HasPrefix(h, p) {
tok := strings.TrimSpace(strings.TrimPrefix(h, p))
if tok == "dev" {
if a, ok := s.devAuth(); ok {
r = r.WithContext(context.WithValue(r.Context(), authCtxKey, a))
next.ServeHTTP(w, r)
return
}
}
}
}
h := r.Header.Get("Authorization")
const p = "Bearer "
if !strings.HasPrefix(h, p) {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing or invalid bearer token")
return
}
raw := strings.TrimSpace(strings.TrimPrefix(h, p))
var matched *apiKeyRecord
for i := range s.apiKeys {
if s.apiKeys[i].token == raw {
matched = &s.apiKeys[i]
break
}
}
if matched == nil {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "unknown api key")
return
}
a := Auth{TenantID: matched.tenantID, Role: matched.role, Token: raw}
r = r.WithContext(context.WithValue(r.Context(), authCtxKey, a))
next.ServeHTTP(w, r)
})
}
func (s *Server) devAuth() (Auth, bool) {
tid, _, _, _, _ := s.store.DemoIDs()
if tid == "" {
return Auth{}, false
}
return Auth{TenantID: tid, Role: "operator", Token: "dev"}, true
}
func roleLevel(role string) int {
switch strings.ToLower(role) {
case "viewer":
return 1
case "editor":
return 2
case "operator":
return 3
default:
return 0
}
}
// requireAtLeast rejects node role and enforces viewer/editor/operator ladder.
func (s *Server) requireAtLeast(w http.ResponseWriter, a Auth, need string) bool {
if strings.ToLower(a.Role) == "node" {
writeProblem(w, http.StatusForbidden, "Forbidden", "node role cannot access this resource")
return false
}
if roleLevel(a.Role) < roleLevel(need) {
writeProblem(w, http.StatusForbidden, "Forbidden", "insufficient role")
return false
}
return true
}
func (s *Server) requireNode(w http.ResponseWriter, a Auth) bool {
if strings.ToLower(a.Role) != "node" {
writeProblem(w, http.StatusForbidden, "Forbidden", "node role required")
return false
}
return true
}
+48
View File
@@ -0,0 +1,48 @@
package httpapi
import (
"net/http"
"strings"
)
func parseCORSOrigins(spec string) []string {
spec = strings.TrimSpace(spec)
if spec == "" {
return nil
}
var out []string
for _, p := range strings.Split(spec, ",") {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
func (s *Server) withCORS(h http.Handler) http.Handler {
if len(s.corsOrigins) == 0 {
return h
}
allowed := make(map[string]struct{}, len(s.corsOrigins))
for _, o := range s.corsOrigins {
allowed[o] = struct{}{}
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin != "" {
if _, ok := allowed[origin]; ok {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Add("Vary", "Origin")
}
}
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, Idempotency-Key, Accept, X-Tenant-Id")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
h.ServeHTTP(w, r)
})
}
+36
View File
@@ -0,0 +1,36 @@
package httpapi
import (
"encoding/json"
"net/http"
)
// Problem is RFC 9457 application/problem+json.
type Problem struct {
Type string `json:"type,omitempty"`
Title string `json:"title"`
Status int `json:"status"`
Detail string `json:"detail,omitempty"`
Instance string `json:"instance,omitempty"`
}
func writeProblem(w http.ResponseWriter, status int, title, detail string) {
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(Problem{
Type: "about:blank",
Title: title,
Status: status,
Detail: detail,
})
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeNoContent(w http.ResponseWriter) {
w.WriteHeader(http.StatusNoContent)
}
+673
View File
@@ -0,0 +1,673 @@
package httpapi
import (
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"net/http"
"os"
"sort"
"strconv"
"strings"
"time"
"evobgp/internal/bundle"
"evobgp/internal/jobs"
"evobgp/internal/observability"
"evobgp/internal/store"
)
// Handler returns the root HTTP handler (system routes public; rest under /v1/ authenticated).
func (s *Server) Handler() http.Handler {
v1 := http.NewServeMux()
s.registerV1(v1)
wrappedV1 := http.StripPrefix("/v1", v1)
s.mux.Handle("GET /metrics", observability.MetricsHandler())
s.mux.HandleFunc("GET /v1/health", s.handleHealth)
s.mux.HandleFunc("GET /v1/ready", s.handleReady)
s.mux.HandleFunc("GET /v1/version", s.handleVersion)
s.mux.Handle("/v1/", s.authMiddleware(wrappedV1))
return s.withCORS(observability.HTTPMiddleware(s.mux))
}
// BundlePublicKeyBase64 returns the Ed25519 public key for verifying bundles (share with evobgp-node).
func (s *Server) BundlePublicKeyBase64() string {
pub := s.bundlePriv.Public().(ed25519.PublicKey)
return base64.StdEncoding.EncodeToString(pub)
}
func (s *Server) registerRoutes() {
// routes attached in Handler()
}
func (s *Server) registerV1(m *http.ServeMux) {
m.HandleFunc("GET /modules", s.handleListModules)
m.HandleFunc("GET /modules/{module_id}", s.handleGetModule)
m.HandleFunc("GET /peers", s.handleListPeers)
m.HandleFunc("GET /speakers", s.handleListSpeakers)
m.HandleFunc("POST /modules/{module_id}/refresh", s.handleModuleRefresh)
m.HandleFunc("GET /revisions", s.handleListRevisions)
m.HandleFunc("GET /revisions/{revision_id}", s.handleGetRevision)
m.HandleFunc("GET /revisions/{revision_id}/preview", s.handleRevisionPreview)
m.HandleFunc("GET /revisions/{revision_a}/diff/{revision_b}", s.handleRevisionDiff)
m.HandleFunc("POST /revisions/{revision_id}/rollback", s.handleRevisionRollback)
m.HandleFunc("POST /apply", s.handleApply)
m.HandleFunc("POST /speakers/{id}/apply", s.handleSpeakerApply)
m.HandleFunc("POST /bird/reload", s.handleBirdReload)
m.HandleFunc("GET /jobs", s.handleListJobs)
m.HandleFunc("GET /jobs/{job_id}", s.handleGetJob)
m.HandleFunc("POST /jobs/{job_id}/cancel", s.handleCancelJob)
m.HandleFunc("GET /speakers/{speaker_id}/revisions/latest", s.handleNodeLatestRevision)
m.HandleFunc("GET /speakers/{speaker_id}/bundle/{revision_id}", s.handleNodeBundle)
m.HandleFunc("POST /nodes/enroll", s.handleNodeEnroll)
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"status": "ready", "checks": map[string]string{"memory_store": "ok"}})
}
func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) {
sha := strings.TrimSpace(os.Getenv("EVOBGP_GIT_SHA"))
if sha == "" {
sha = "unknown"
}
writeJSON(w, http.StatusOK, map[string]string{"api_version": "0.1.0", "git_sha": sha})
}
func moduleJSON(mod *store.Module) map[string]any {
m := map[string]any{
"id": mod.ID,
"type": mod.Type,
"name": mod.Name,
"enabled": mod.Enabled,
"priority": mod.Priority,
"refresh_interval_sec": mod.RefreshIntervalSec,
"cron_expr": mod.CronExpr,
}
if mod.DefaultCommunityID != nil {
m["default_community_id"] = *mod.DefaultCommunityID
} else {
m["default_community_id"] = nil
}
if mod.DohProfileID != nil {
m["doh_profile_id"] = *mod.DohProfileID
} else {
m["doh_profile_id"] = nil
}
return m
}
func peerJSON(p *store.BGPPeer) map[string]any {
m := map[string]any{
"id": p.ID,
"name": p.Name,
"neighbor": p.Neighbor,
"session_state": p.SessionState,
}
if p.SpeakerID != nil {
m["bgp_speaker_id"] = *p.SpeakerID
} else {
m["bgp_speaker_id"] = nil
}
return m
}
func speakerJSON(sp *store.Speaker) map[string]any {
m := map[string]any{
"id": sp.ID,
"role": sp.Role,
"endpoint": sp.Endpoint,
}
if sp.LastAppliedRevisionID != nil {
m["last_applied_revision_id"] = *sp.LastAppliedRevisionID
} else {
m["last_applied_revision_id"] = nil
}
return m
}
func (s *Server) handleListModules(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
return
}
mods := s.store.ListModules(a.TenantID)
items := make([]map[string]any, 0, len(mods))
for _, mod := range mods {
items = append(items, moduleJSON(mod))
}
writeJSON(w, http.StatusOK, map[string]any{
"items": items, "next_cursor": nil, "has_more": false,
})
}
func (s *Server) handleGetModule(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
return
}
mod, err := s.store.GetModule(a.TenantID, r.PathValue("module_id"))
if err != nil {
if err == store.ErrNotFound || err == store.ErrTenantScope {
writeProblem(w, http.StatusNotFound, "Not Found", "module not found")
return
}
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
return
}
writeJSON(w, http.StatusOK, moduleJSON(mod))
}
func (s *Server) handleListPeers(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
return
}
peers := s.store.ListPeers(a.TenantID)
items := make([]map[string]any, 0, len(peers))
for _, p := range peers {
items = append(items, peerJSON(p))
}
writeJSON(w, http.StatusOK, map[string]any{
"items": items, "next_cursor": nil, "has_more": false,
})
}
func (s *Server) handleListSpeakers(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
return
}
speakers := s.store.ListSpeakersForTenant(a.TenantID)
items := make([]map[string]any, 0, len(speakers))
for _, sp := range speakers {
items = append(items, speakerJSON(sp))
}
writeJSON(w, http.StatusOK, map[string]any{
"items": items, "next_cursor": nil, "has_more": false,
})
}
func (s *Server) handleModuleRefresh(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "editor") {
return
}
moduleID := r.PathValue("module_id")
mod, err := s.store.GetModule(a.TenantID, moduleID)
if err != nil {
if err == store.ErrNotFound || err == store.ErrTenantScope {
writeProblem(w, http.StatusNotFound, "Not Found", "module not found")
return
}
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
return
}
if mod.Type == "IP_RANGES" {
writeNoContent(w)
return
}
idem := r.Header.Get("Idempotency-Key")
var idemPtr *string
if strings.TrimSpace(idem) != "" {
idem = strings.TrimSpace(idem)
idemPtr = &idem
}
mid := mod.ID
j, _, err := s.jobs.Enqueue(a.TenantID, jobs.KindModuleRefresh, idemPtr, &mid, map[string]any{"module_id": moduleID})
if err != nil {
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
return
}
w.Header().Set("Location", "/v1/jobs/"+j.ID)
snap := j.Snapshot()
writeJSON(w, http.StatusAccepted, map[string]any{"job_id": snap["job_id"], "status": snap["status"]})
}
func (s *Server) handleListRevisions(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
return
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit == 0 {
limit = 50
}
cursor := r.URL.Query().Get("cursor")
moduleID := r.URL.Query().Get("module_id")
items, next, more := s.store.ListRevisions(a.TenantID, moduleID, cursor, limit)
out := make([]map[string]any, 0, len(items))
for _, rev := range items {
out = append(out, revisionJSON(rev))
}
writeJSON(w, http.StatusOK, map[string]any{
"items": out, "next_cursor": strPtrOrNull(next), "has_more": more,
})
}
func revisionJSON(rev *store.Revision) map[string]any {
m := map[string]any{
"id": rev.ID,
"content_hash": rev.ContentHash,
"created_at": rev.CreatedAt.UTC().Format(time.RFC3339Nano),
"materialized_prefix_count": rev.MaterializedPrefixCount,
}
if rev.ModuleID != "" {
m["module_id"] = rev.ModuleID
} else {
m["module_id"] = nil
}
if rev.ParentRevisionID != nil {
m["parent_revision_id"] = *rev.ParentRevisionID
} else {
m["parent_revision_id"] = nil
}
return m
}
func strPtrOrNull(s string) any {
if s == "" {
return nil
}
return s
}
func (s *Server) handleGetRevision(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
return
}
rev, err := s.store.GetRevision(a.TenantID, r.PathValue("revision_id"))
if err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "revision not found")
return
}
writeJSON(w, http.StatusOK, revisionJSON(rev))
}
func (s *Server) handleRevisionPreview(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
return
}
rev, err := s.store.GetRevision(a.TenantID, r.PathValue("revision_id"))
if err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "revision not found")
return
}
acc := r.Header.Get("Accept")
if strings.Contains(acc, "text/plain") && !strings.Contains(acc, "application/json") {
var b strings.Builder
for _, k := range sortedFragmentKeys(rev.PreviewFragments) {
b.WriteString("# --- ")
b.WriteString(k)
b.WriteString(" ---\n")
b.WriteString(rev.PreviewFragments[k])
b.WriteByte('\n')
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(b.String()))
return
}
obj := make(map[string]any, len(rev.PreviewFragments)+1)
for k, v := range rev.PreviewFragments {
obj[k] = v
}
writeJSON(w, http.StatusOK, obj)
}
func (s *Server) handleRevisionDiff(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
return
}
d, err := s.store.RevisionDiff(a.TenantID, r.PathValue("revision_a"), r.PathValue("revision_b"))
if err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "revision not found")
return
}
writeJSON(w, http.StatusOK, d)
}
func (s *Server) handleRevisionRollback(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "editor") {
return
}
revID := r.PathValue("revision_id")
if _, err := s.store.GetRevision(a.TenantID, revID); err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "revision not found")
return
}
idem := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
var idemPtr *string
if idem != "" {
idemPtr = &idem
}
j, _, err := s.jobs.Enqueue(a.TenantID, jobs.KindRevisionRollback, idemPtr, nil, map[string]any{
"source_revision_id": revID,
})
if err != nil {
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
return
}
w.Header().Set("Location", "/v1/jobs/"+j.ID)
snap := j.Snapshot()
writeJSON(w, http.StatusAccepted, map[string]any{"job_id": snap["job_id"], "status": snap["status"]})
}
func (s *Server) handleApply(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if strings.ToLower(a.Role) != "operator" {
writeProblem(w, http.StatusForbidden, "Forbidden", "operator role required")
return
}
var body struct {
RevisionID string `json:"revision_id"`
Strategy string `json:"strategy"`
DryRun bool `json:"dry_run"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
revID := strings.TrimSpace(body.RevisionID)
if revID == "" {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "revision_id required")
return
}
if _, err := s.store.GetRevision(a.TenantID, revID); err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "revision not found")
return
}
if body.DryRun {
writeJSON(w, http.StatusOK, map[string]any{"dry_run": true, "revision_id": revID})
return
}
idem := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
var idemPtr *string
if idem != "" {
idemPtr = &idem
}
j, _, err := s.jobs.Enqueue(a.TenantID, jobs.KindDeployApply, idemPtr, nil, map[string]any{
"revision_id": revID,
"strategy": body.Strategy,
})
if err != nil {
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
return
}
w.Header().Set("Location", "/v1/jobs/"+j.ID)
snap := j.Snapshot()
writeJSON(w, http.StatusAccepted, map[string]any{"job_id": snap["job_id"], "status": snap["status"]})
}
func (s *Server) handleSpeakerApply(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if strings.ToLower(a.Role) != "operator" {
writeProblem(w, http.StatusForbidden, "Forbidden", "operator role required")
return
}
spkID := r.PathValue("id")
if _, err := s.store.GetSpeaker(a.TenantID, spkID); err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "speaker not found")
return
}
var body struct {
RevisionID string `json:"revision_id"`
DryRun bool `json:"dry_run"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
revID := strings.TrimSpace(body.RevisionID)
if revID == "" {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "revision_id required")
return
}
if _, err := s.store.GetRevision(a.TenantID, revID); err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "revision not found")
return
}
if body.DryRun {
writeJSON(w, http.StatusOK, map[string]any{"dry_run": true})
return
}
idem := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
var idemPtr *string
if idem != "" {
idemPtr = &idem
}
j, _, err := s.jobs.Enqueue(a.TenantID, jobs.KindDeployApply, idemPtr, nil, map[string]any{
"revision_id": revID,
"speaker_id": spkID,
})
if err != nil {
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
return
}
w.Header().Set("Location", "/v1/jobs/"+j.ID)
snap := j.Snapshot()
writeJSON(w, http.StatusAccepted, map[string]any{"job_id": snap["job_id"], "status": snap["status"]})
}
func (s *Server) handleBirdReload(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if strings.ToLower(a.Role) != "operator" {
writeProblem(w, http.StatusForbidden, "Forbidden", "operator role required")
return
}
idem := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
var idemPtr *string
if idem != "" {
idemPtr = &idem
}
j, _, err := s.jobs.Enqueue(a.TenantID, jobs.KindBirdReload, idemPtr, nil, nil)
if err != nil {
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
return
}
w.Header().Set("Location", "/v1/jobs/"+j.ID)
writeJSON(w, http.StatusAccepted, map[string]any{"job_id": j.ID, "status": "queued"})
}
func (s *Server) handleListJobs(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
return
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
status := r.URL.Query().Get("status")
kind := r.URL.Query().Get("kind")
cursor := r.URL.Query().Get("cursor")
list, next, more := s.jobs.List(a.TenantID, status, kind, cursor, limit)
items := make([]map[string]any, 0, len(list))
for _, j := range list {
items = append(items, j.Snapshot())
}
writeJSON(w, http.StatusOK, map[string]any{
"items": items, "next_cursor": strPtrOrNull(next), "has_more": more,
})
}
func (s *Server) handleGetJob(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
return
}
j, err := s.jobs.Get(a.TenantID, r.PathValue("job_id"))
if err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "job not found")
return
}
writeJSON(w, http.StatusOK, j.Snapshot())
}
func (s *Server) handleCancelJob(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "editor") {
return
}
j, err := s.jobs.RequestCancel(a.TenantID, r.PathValue("job_id"))
if err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "job not found")
return
}
writeJSON(w, http.StatusAccepted, j.Snapshot())
}
func (s *Server) handleNodeLatestRevision(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireNode(w, a) {
return
}
sid := r.PathValue("speaker_id")
sp, err := s.store.GetSpeakerAnyTenant(sid)
if err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "speaker not found")
return
}
if sp.TenantID != a.TenantID {
writeProblem(w, http.StatusForbidden, "Forbidden", "speaker not in tenant scope")
return
}
rid, at, err := s.store.LatestPublishedRevision(sid)
if err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "no published revision")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"revision_id": rid, "published_at": at.UTC().Format(time.RFC3339Nano),
})
}
func (s *Server) handleNodeBundle(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireNode(w, a) {
return
}
sid := r.PathValue("speaker_id")
rid := r.PathValue("revision_id")
sp, err := s.store.GetSpeakerAnyTenant(sid)
if err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "speaker not found")
return
}
if sp.TenantID != a.TenantID {
writeProblem(w, http.StatusForbidden, "Forbidden", "speaker not in tenant scope")
return
}
rev, err := s.store.GetRevision(a.TenantID, rid)
if err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "revision not found")
return
}
tgz, err := bundle.BuildGzippedTar(rid, sid, rev.PreviewFragments, s.bundlePriv)
if err != nil {
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
return
}
w.Header().Set("Content-Type", "application/gzip")
w.Header().Set("Content-Disposition", `attachment; filename="bundle.tar.gz"`)
w.WriteHeader(http.StatusOK)
_, _ = w.Write(tgz)
}
func (s *Server) handleNodeEnroll(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireNode(w, a) {
return
}
var req map[string]any
_ = json.NewDecoder(r.Body).Decode(&req)
writeJSON(w, http.StatusOK, map[string]any{
"status": "accepted",
"message": "enrollment stub; operator approval required in production",
})
}
func sortedFragmentKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
+78
View File
@@ -0,0 +1,78 @@
package httpapi
import (
"crypto/ed25519"
"crypto/rand"
"encoding/hex"
"errors"
"net/http"
"strings"
"evobgp/internal/jobs"
"evobgp/internal/observability"
"evobgp/internal/store"
)
// Server implements EvoBGP control-plane HTTP API (subset focused on jobs, deploy, node bundle).
type Server struct {
store *store.Memory
jobs *jobs.Registry
bundlePriv ed25519.PrivateKey
apiKeys []apiKeyRecord
insecureDev bool
corsOrigins []string
mux *http.ServeMux
}
// Options configures the API server.
type Options struct {
// APIKeys is comma-separated "token|tenantUUID|role" (role: viewer, editor, operator, node).
APIKeys string
// InsecureDev with SeedDemo allows Bearer "dev" as operator for the demo tenant (local only).
InsecureDev bool
SeedDemo bool
// BundleSeedHex is 64 hex chars (32 bytes) for deterministic Ed25519 bundle signing; if empty, random.
BundleSeedHex string
// CORSAllowedOrigins is comma-separated list of allowed browser Origins (e.g. http://localhost:4173).
CORSAllowedOrigins string
}
// New constructs Server and wiring for async jobs.
func New(opts Options) (*Server, error) {
mem := store.NewMemory()
if opts.SeedDemo {
mem.SeedDemo()
}
wk := &jobs.Worker{Store: mem}
reg := jobs.NewRegistry(wk.Process)
var priv ed25519.PrivateKey
if strings.TrimSpace(opts.BundleSeedHex) != "" {
seed, err := hex.DecodeString(strings.TrimSpace(opts.BundleSeedHex))
if err != nil {
return nil, err
}
if len(seed) != ed25519.SeedSize {
return nil, errors.New("httpapi: BundleSeedHex must decode to 32 bytes")
}
priv = ed25519.NewKeyFromSeed(seed)
} else {
_, priv, _ = ed25519.GenerateKey(rand.Reader)
}
s := &Server{
store: mem,
jobs: reg,
bundlePriv: priv,
apiKeys: parseAPIKeysSpec(opts.APIKeys),
insecureDev: opts.InsecureDev && opts.SeedDemo,
corsOrigins: parseCORSOrigins(opts.CORSAllowedOrigins),
}
observability.RegisterStoreMetrics(mem)
s.mux = http.NewServeMux()
s.registerRoutes()
return s, nil
}
// Store exposes the in-memory store (for operators / tests).
func (s *Server) Store() *store.Memory { return s.store }
+228
View File
@@ -0,0 +1,228 @@
package httpapi
import (
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"evobgp/internal/jobs"
"evobgp/internal/signing"
)
const testBundleSeed = "0101010101010101010101010101010101010101010101010101010101010101"
func TestAPIRefreshApplyJobsBundle(t *testing.T) {
srv, err := New(Options{
InsecureDev: true,
SeedDemo: true,
BundleSeedHex: testBundleSeed,
})
if err != nil {
t.Fatal(err)
}
tenant, modCDN, modIP, rev, speaker := srv.Store().DemoIDs()
srv.apiKeys = parseAPIKeysSpec("nodekey|" + tenant + "|node,opkey|" + tenant + "|operator")
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
client := ts.Client()
base := ts.URL
t.Run("prometheus metrics", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodGet, base+"/metrics", nil)
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
}
raw, _ := io.ReadAll(resp.Body)
s := string(raw)
for _, needle := range []string{
"evobgp_materialized_prefixes_max",
"evobgp_bgp_peers_configured_total",
"evobgp_http_requests_total", // incremented by this scrape request
} {
if !strings.Contains(s, needle) {
t.Fatalf("metrics body missing %q", needle)
}
}
})
t.Run("refresh IP_RANGES no op", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodPost, base+"/v1/modules/"+modIP+"/refresh", nil)
req.Header.Set("Authorization", "Bearer opkey")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
}
})
t.Run("refresh CDN queues job", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodPost, base+"/v1/modules/"+modCDN+"/refresh", nil)
req.Header.Set("Authorization", "Bearer opkey")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusAccepted {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
}
var body struct {
JobID string `json:"job_id"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatal(err)
}
waitJob(t, client, base, "opkey", body.JobID)
})
t.Run("preview revision", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodGet, base+"/v1/revisions/"+rev+"/preview", nil)
req.Header.Set("Authorization", "Bearer opkey")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
}
})
t.Run("list modules peers speakers", func(t *testing.T) {
for _, path := range []string{"/v1/modules", "/v1/peers", "/v1/speakers"} {
req, _ := http.NewRequest(http.MethodGet, base+path, nil)
req.Header.Set("Authorization", "Bearer opkey")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
b, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("%s status %d: %s", path, resp.StatusCode, b)
}
var body struct {
Items []map[string]any `json:"items"`
}
if err := json.Unmarshal(b, &body); err != nil {
t.Fatalf("%s json: %v", path, err)
}
if len(body.Items) < 1 {
t.Fatalf("%s expected items", path)
}
}
})
t.Run("rollback queues job", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodPost, base+"/v1/revisions/"+rev+"/rollback", nil)
req.Header.Set("Authorization", "Bearer opkey")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusAccepted {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
}
var body struct {
JobID string `json:"job_id"`
}
_ = json.NewDecoder(resp.Body).Decode(&body)
waitJob(t, client, base, "opkey", body.JobID)
})
t.Run("apply all speakers", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodPost, base+"/v1/apply", strings.NewReader(`{"revision_id":"`+rev+`"}`))
req.Header.Set("Authorization", "Bearer opkey")
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusAccepted {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
}
var body struct {
JobID string `json:"job_id"`
}
_ = json.NewDecoder(resp.Body).Decode(&body)
waitJob(t, client, base, "opkey", body.JobID)
})
pubB64 := srv.BundlePublicKeyBase64()
pubBytes, err := base64.StdEncoding.DecodeString(pubB64)
if err != nil {
t.Fatal(err)
}
t.Run("node bundle roundtrip verify", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodGet, base+"/v1/speakers/"+speaker+"/bundle/"+rev, nil)
req.Header.Set("Authorization", "Bearer nodekey")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
}
raw, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
_, err = signing.VerifyGzippedTar(raw, ed25519.PublicKey(pubBytes))
if err != nil {
t.Fatal(err)
}
})
}
func waitJob(t *testing.T, client *http.Client, base, token, jobID string) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
req, _ := http.NewRequest(http.MethodGet, base+"/v1/jobs/"+jobID, nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
b, _ := io.ReadAll(resp.Body)
resp.Body.Close()
var body struct {
Status string `json:"status"`
}
_ = json.Unmarshal(b, &body)
if body.Status == jobs.StatusSucceeded || body.Status == jobs.StatusFailed {
if body.Status != jobs.StatusSucceeded {
t.Fatalf("job %s status %s", jobID, body.Status)
}
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("job %s did not complete", jobID)
}
+23
View File
@@ -0,0 +1,23 @@
// Package ingest fetches CDN lists, resolves DoH domains, and writes normalized prefixes into the database.
package ingest
import (
"context"
"log"
"time"
)
// Run blocks until ctx is cancelled. Reference deployment: separate process consuming the job queue.
func Run(ctx context.Context) {
t := time.NewTicker(60 * time.Second)
defer t.Stop()
log.Printf("evobgp-ingest: started (stub; ingest workers dequeue from broker or job_audit)")
for {
select {
case <-ctx.Done():
log.Printf("evobgp-ingest: stopped")
return
case <-t.C:
}
}
}
+5
View File
@@ -0,0 +1,5 @@
package jobs
import "errors"
var ErrNotFound = errors.New("jobs: not found")
+285
View File
@@ -0,0 +1,285 @@
package jobs
import (
"fmt"
"sort"
"sync"
"time"
"github.com/google/uuid"
)
// Status values align with OpenAPI JobStatus and DB constraint job_audit_status_chk.
const (
StatusQueued = "queued"
StatusRunning = "running"
StatusSucceeded = "succeeded"
StatusFailed = "failed"
StatusCancelled = "cancelled"
)
// Job is the API-facing job model (maps to job_audit).
type Job struct {
ID string
TenantID string
Kind string
Status string
IdempotencyKey *string
ModuleID *string
CreatedAt time.Time
StartedAt *time.Time
FinishedAt *time.Time
Error *string
ProgressPct *int16
Meta map[string]any
cancelRequested bool
mu sync.Mutex
}
func (j *Job) MarkRunning() {
j.mu.Lock()
defer j.mu.Unlock()
if j.Status != StatusQueued {
return
}
now := time.Now().UTC()
j.StartedAt = &now
j.Status = StatusRunning
}
func (j *Job) Succeed() {
j.mu.Lock()
defer j.mu.Unlock()
now := time.Now().UTC()
j.FinishedAt = &now
j.Status = StatusSucceeded
}
func (j *Job) Fail(msg string) {
j.mu.Lock()
defer j.mu.Unlock()
now := time.Now().UTC()
j.FinishedAt = &now
j.Status = StatusFailed
j.Error = &msg
}
func (j *Job) MarkCancelled() {
j.mu.Lock()
defer j.mu.Unlock()
if j.Status == StatusSucceeded || j.Status == StatusFailed || j.Status == StatusCancelled {
return
}
now := time.Now().UTC()
j.FinishedAt = &now
j.Status = StatusCancelled
}
func (j *Job) IsCancelRequested() bool {
j.mu.Lock()
defer j.mu.Unlock()
return j.cancelRequested
}
func (j *Job) RequestCancel() bool {
j.mu.Lock()
defer j.mu.Unlock()
j.cancelRequested = true
if j.Status == StatusQueued {
now := time.Now().UTC()
j.FinishedAt = &now
j.Status = StatusCancelled
return true
}
return false
}
// Snapshot returns a consistent view for JSON serialization (safe under concurrent worker updates).
func (j *Job) mergeMeta(kv map[string]any) {
j.mu.Lock()
defer j.mu.Unlock()
if j.Meta == nil {
j.Meta = map[string]any{}
}
for k, v := range kv {
j.Meta[k] = v
}
}
// statusLocked is used by the worker defer for metrics (any stable terminal or in-flight status).
func (j *Job) statusLocked() string {
j.mu.Lock()
defer j.mu.Unlock()
return j.Status
}
func (j *Job) Snapshot() map[string]any {
j.mu.Lock()
defer j.mu.Unlock()
metaCopy := make(map[string]any, len(j.Meta))
for k, v := range j.Meta {
metaCopy[k] = v
}
m := map[string]any{
"job_id": j.ID, "kind": j.Kind, "status": j.Status,
"created_at": j.CreatedAt.UTC().Format(time.RFC3339Nano),
"meta": metaCopy,
}
if j.IdempotencyKey != nil {
m["idempotency_key"] = *j.IdempotencyKey
} else {
m["idempotency_key"] = nil
}
if j.StartedAt != nil {
m["started_at"] = j.StartedAt.UTC().Format(time.RFC3339Nano)
} else {
m["started_at"] = nil
}
if j.FinishedAt != nil {
m["finished_at"] = j.FinishedAt.UTC().Format(time.RFC3339Nano)
} else {
m["finished_at"] = nil
}
if j.Error != nil {
m["error"] = *j.Error
} else {
m["error"] = nil
}
return m
}
// Registry tracks jobs in memory (microVPS-style single process; swap for PG + SKIP LOCKED later).
type Registry struct {
mu sync.RWMutex
byID map[string]*Job
byIdempo map[idempoKey]*Job
workerStart func(j *Job)
}
type idempoKey struct {
tenant string
key string
}
func NewRegistry(workerStart func(j *Job)) *Registry {
return &Registry{
byID: make(map[string]*Job),
byIdempo: make(map[idempoKey]*Job),
workerStart: workerStart,
}
}
// Enqueue creates a job or returns an existing one for the same idempotency key.
func (r *Registry) Enqueue(tenantID, kind string, idempotencyKey *string, moduleID *string, meta map[string]any) (*Job, bool, error) {
r.mu.Lock()
defer r.mu.Unlock()
if idempotencyKey != nil && *idempotencyKey != "" {
k := idempoKey{tenant: tenantID, key: *idempotencyKey}
if existing, ok := r.byIdempo[k]; ok {
return existing, false, nil
}
}
j := &Job{
ID: uuid.NewString(),
TenantID: tenantID,
Kind: kind,
Status: StatusQueued,
IdempotencyKey: idempotencyKey,
ModuleID: moduleID,
CreatedAt: time.Now().UTC(),
Meta: cloneMeta(meta),
}
if idempotencyKey != nil && *idempotencyKey != "" {
r.byIdempo[idempoKey{tenant: tenantID, key: *idempotencyKey}] = j
}
r.byID[j.ID] = j
if r.workerStart != nil {
go r.workerStart(j)
}
return j, true, nil
}
func cloneMeta(m map[string]any) map[string]any {
if m == nil {
return map[string]any{}
}
out := make(map[string]any, len(m))
for k, v := range m {
out[k] = v
}
return out
}
func (r *Registry) Get(tenantID, jobID string) (*Job, error) {
r.mu.RLock()
defer r.mu.RUnlock()
j, ok := r.byID[jobID]
if !ok || j.TenantID != tenantID {
return nil, ErrNotFound
}
return j, nil
}
func (r *Registry) List(tenantID, statusFilter, kindFilter, cursor string, limit int) ([]*Job, string, bool) {
if limit <= 0 {
limit = 50
}
r.mu.RLock()
var all []*Job
for _, j := range r.byID {
if j.TenantID != tenantID {
continue
}
if statusFilter != "" && j.Status != statusFilter {
continue
}
if kindFilter != "" && j.Kind != kindFilter {
continue
}
all = append(all, j)
}
r.mu.RUnlock()
sort.Slice(all, func(i, j int) bool {
return all[i].CreatedAt.After(all[j].CreatedAt)
})
off := 0
if cursor != "" {
_ = parseCursor(cursor, &off)
}
end := off + limit
next := ""
hasMore := false
if end > len(all) {
end = len(all)
} else {
hasMore = true
next = formatCursor(end)
}
if off >= len(all) {
return nil, "", false
}
return all[off:end], next, hasMore
}
func parseCursor(s string, off *int) error {
_, err := fmt.Sscanf(s, "%d", off)
return err
}
func formatCursor(off int) string {
return fmt.Sprintf("%d", off)
}
// RequestCancel marks a job for cancellation (best-effort).
func (r *Registry) RequestCancel(tenantID, jobID string) (*Job, error) {
j, err := r.Get(tenantID, jobID)
if err != nil {
return nil, err
}
j.RequestCancel()
return j, nil
}
+98
View File
@@ -0,0 +1,98 @@
package jobs
import (
"evobgp/internal/observability"
"evobgp/internal/store"
)
const (
KindModuleRefresh = "module_refresh"
KindDeployApply = "deploy_apply"
KindRevisionRollback = "revision_rollback"
KindBirdReload = "bird_reload"
)
// Worker executes queued jobs against an in-memory store (stub for full render/deploy pipeline).
type Worker struct {
Store *store.Memory
}
// Process is registered as Registry.workerStart.
func (w *Worker) Process(j *Job) {
defer func() {
observability.RecordJobTerminal(j.Kind, j.statusLocked())
}()
if w == nil || w.Store == nil {
j.MarkRunning()
j.Fail("worker not configured")
return
}
j.MarkRunning()
if j.IsCancelRequested() {
j.MarkCancelled()
return
}
switch j.Kind {
case KindModuleRefresh:
j.Succeed()
case KindDeployApply:
w.runDeployApply(j)
case KindRevisionRollback:
w.runRollback(j)
case KindBirdReload:
j.Succeed()
default:
j.Fail("unknown job kind")
}
}
func (w *Worker) runDeployApply(j *Job) {
rev, _ := j.Meta["revision_id"].(string)
spk, hasSpeaker := j.Meta["speaker_id"].(string)
if rev == "" {
j.Fail("missing revision_id in job meta")
return
}
applyOne := func(speakerID string) error {
if err := w.Store.SetLastAppliedRevision(j.TenantID, speakerID, rev); err != nil {
return err
}
// Replica / node pulls use LatestPublishedRevision; keep pointer in sync with successful deploy.
if err := w.Store.PublishRevisionForSpeaker(speakerID, rev); err != nil {
return err
}
return nil
}
if hasSpeaker && spk != "" {
if err := applyOne(spk); err != nil {
j.Fail(err.Error())
return
}
j.Succeed()
return
}
for _, sp := range w.Store.ListSpeakersForTenant(j.TenantID) {
if err := applyOne(sp.ID); err != nil {
j.Fail(err.Error())
return
}
}
j.Succeed()
}
func (w *Worker) runRollback(j *Job) {
src, _ := j.Meta["source_revision_id"].(string)
if src == "" {
j.Fail("missing source_revision_id in job meta")
return
}
newID, err := w.Store.CreateRollbackRevision(j.TenantID, src)
if err != nil {
j.Fail(err.Error())
return
}
j.mergeMeta(map[string]any{"new_revision_id": newID})
j.Succeed()
}
+224
View File
@@ -0,0 +1,224 @@
// Package observability registers Prometheus metrics for the control plane (prefix aggregates, BGP peers, jobs, HTTP).
package observability
import (
"context"
"net/http"
"strconv"
"sync"
"sync/atomic"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/prometheus/client_golang/prometheus/promhttp"
"evobgp/internal/store"
)
const namespace = "evobgp"
var (
metricsMem atomic.Pointer[store.Memory]
registerCollectorOnce sync.Once
)
var (
httpRequests = promauto.NewCounterVec(
prometheus.CounterOpts{
Namespace: namespace,
Name: "http_requests_total",
Help: "HTTP requests handled by the API mux (excludes /metrics).",
},
[]string{"method", "code"},
)
jobsFinished = promauto.NewCounterVec(
prometheus.CounterOpts{
Namespace: namespace,
Name: "jobs_finished_total",
Help: "Async jobs that reached a terminal state.",
},
[]string{"kind", "status"},
)
birdBGPEstablished = promauto.NewGauge(prometheus.GaugeOpts{
Namespace: namespace,
Name: "bird_bgp_sessions_established",
Help: "BGP sessions in Established state from birdc show protocols (0 if scrape disabled or failed).",
})
birdProtocolsScrapeSuccess = promauto.NewGauge(prometheus.GaugeOpts{
Namespace: namespace,
Name: "bird_protocols_scrape_success",
Help: "1 if the last birdc protocols scrape succeeded, else 0.",
})
buildInfo = promauto.NewGaugeVec(prometheus.GaugeOpts{
Namespace: namespace,
Name: "build_info",
Help: "Build metadata (value always 1).",
}, []string{"version", "git_sha"})
)
// RecordJobTerminal increments jobs_finished_total for terminal statuses.
func RecordJobTerminal(kind, status string) {
switch status {
case "succeeded", "failed", "cancelled":
jobsFinished.WithLabelValues(kind, status).Inc()
default:
return
}
}
// SetBuildInfo sets evobgp_build_info gauge (idempotent labels).
func SetBuildInfo(version, gitSHA string) {
if version == "" {
version = "unknown"
}
if gitSHA == "" {
gitSHA = "unknown"
}
buildInfo.WithLabelValues(version, gitSHA).Set(1)
}
type memoryStoreCollector struct {
prefixMaxDesc *prometheus.Desc
prefixSumDesc *prometheus.Desc
peersDesc *prometheus.Desc
peerStateDesc *prometheus.Desc
}
func newMemoryStoreCollector() *memoryStoreCollector {
return &memoryStoreCollector{
prefixMaxDesc: prometheus.NewDesc(
prometheus.BuildFQName(namespace, "", "materialized_prefixes_max"),
"Maximum materialized_prefix_count among all revisions in the store.",
nil, nil,
),
prefixSumDesc: prometheus.NewDesc(
prometheus.BuildFQName(namespace, "", "materialized_prefixes_sum"),
"Sum of materialized_prefix_count over revisions (development aggregate).",
nil, nil,
),
peersDesc: prometheus.NewDesc(
prometheus.BuildFQName(namespace, "", "bgp_peers_configured_total"),
"BGP peers configured in the control-plane store.",
nil, nil,
),
peerStateDesc: prometheus.NewDesc(
prometheus.BuildFQName(namespace, "", "bgp_peer_sessions"),
"Configured BGP peers in the store by session_state (intent / last known, not live BIRD).",
[]string{"state"}, nil,
),
}
}
func (c *memoryStoreCollector) Describe(ch chan<- *prometheus.Desc) {
ch <- c.prefixMaxDesc
ch <- c.prefixSumDesc
ch <- c.peersDesc
ch <- c.peerStateDesc
}
func (c *memoryStoreCollector) Collect(ch chan<- prometheus.Metric) {
mem := metricsMem.Load()
if mem == nil {
return
}
maxN, sumN := mem.MaterializedPrefixStats()
ch <- prometheus.MustNewConstMetric(c.prefixMaxDesc, prometheus.GaugeValue, float64(maxN))
ch <- prometheus.MustNewConstMetric(c.prefixSumDesc, prometheus.GaugeValue, float64(sumN))
ch <- prometheus.MustNewConstMetric(c.peersDesc, prometheus.GaugeValue, float64(mem.PeerCount()))
for state, n := range mem.PeerSessionCountsByState() {
if state == "" {
state = "unknown"
}
ch <- prometheus.MustNewConstMetric(c.peerStateDesc, prometheus.GaugeValue, float64(n), state)
}
}
// RegisterStoreMetrics points Prometheus collectors at the given store (last call wins; safe for tests).
func RegisterStoreMetrics(mem *store.Memory) {
if mem == nil {
return
}
metricsMem.Store(mem)
registerCollectorOnce.Do(func() {
prometheus.DefaultRegisterer.MustRegister(newMemoryStoreCollector())
})
}
// SetBirdSessionMetrics updates gauges from an optional birdc scrape.
func SetBirdSessionMetrics(established int, scrapeOK bool) {
birdBGPEstablished.Set(float64(established))
if scrapeOK {
birdProtocolsScrapeSuccess.Set(1)
} else {
birdProtocolsScrapeSuccess.Set(0)
}
}
// MetricsHandler returns the Prometheus scrape handler.
func MetricsHandler() http.Handler {
return promhttp.HandlerFor(prometheus.DefaultGatherer, promhttp.HandlerOpts{})
}
// HTTPMiddleware records method and status code for all wrapped requests.
func HTTPMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sw := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(sw, r)
httpRequests.WithLabelValues(r.Method, strconv.Itoa(sw.status)).Inc()
})
}
type statusRecorder struct {
http.ResponseWriter
status int
}
func (s *statusRecorder) WriteHeader(code int) {
s.status = code
s.ResponseWriter.WriteHeader(code)
}
// StartBirdProtocolsPoller runs birdc "show protocols" on interval when socket is non-empty.
func StartBirdProtocolsPoller(socket string, birdcPath string, interval time.Duration, showFn func(ctx context.Context, socket, birdcBin string) (string, error), countFn func(output string) int) {
socket = trimSpace(socket)
if socket == "" || interval <= 0 || showFn == nil || countFn == nil {
return
}
scrape := func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
out, err := showFn(ctx, socket, birdcPath)
cancel()
if err != nil {
SetBirdSessionMetrics(0, false)
return
}
SetBirdSessionMetrics(countFn(out), true)
}
go func() {
scrape()
t := time.NewTicker(interval)
defer t.Stop()
for range t.C {
scrape()
}
}()
}
func trimSpace(s string) string {
for len(s) > 0 && (s[0] == ' ' || s[0] == '\t') {
s = s[1:]
}
for len(s) > 0 {
last := s[len(s)-1]
if last != ' ' && last != '\t' {
break
}
s = s[:len(s)-1]
}
return s
}
+16
View File
@@ -0,0 +1,16 @@
// Package platform groups cross-cutting runtime concerns (logging hooks, build/service identity).
// Metrics and HTTP middleware live in internal/observability for now.
package platform
import (
"os"
"strings"
)
// ServiceName returns EVOBGP_SERVICE or defaultName when unset.
func ServiceName(defaultName string) string {
if v := strings.TrimSpace(os.Getenv("EVOBGP_SERVICE")); v != "" {
return v
}
return defaultName
}
+23
View File
@@ -0,0 +1,23 @@
// Package render materializes prefix sets, creates config_revision rows, and builds BIRD text via internal/birdfmt.
package render
import (
"context"
"log"
"time"
)
// Run blocks until ctx is cancelled. Reference deployment: worker process after ingest completes.
func Run(ctx context.Context) {
t := time.NewTicker(60 * time.Second)
defer t.Stop()
log.Printf("evobgp-render: started (stub; render jobs produce revisions and artifacts)")
for {
select {
case <-ctx.Done():
log.Printf("evobgp-render: stopped")
return
case <-t.C:
}
}
}
+6
View File
@@ -0,0 +1,6 @@
// Package repository will host SQL-backed data access (plan §1: tenant, module, peer, revision, jobs).
//
// The in-memory implementation used by the current API lives in internal/store.
// Migrations: see migrations/ at repository root.
package repository
+25
View File
@@ -0,0 +1,25 @@
// Package scheduler drives module/CDN refresh intervals and enqueues work (broker or job_audit).
// This process entrypoint is a stub until PostgreSQL and NATS/Redis are wired.
package scheduler
import (
"context"
"log"
"time"
)
// Run blocks until ctx is cancelled. In production it connects to DB + broker and schedules refresh events.
func Run(ctx context.Context) {
t := time.NewTicker(60 * time.Second)
defer t.Stop()
log.Printf("evobgp-scheduler: started (stub; connect EVOBGP_DATABASE_URL / EVOBGP_BROKER_URL for full stack)")
for {
select {
case <-ctx.Done():
log.Printf("evobgp-scheduler: stopped")
return
case <-t.C:
// Placeholder: evaluate module cron / refresh_interval and publish jobs.
}
}
}
+13
View File
@@ -0,0 +1,13 @@
// Package signing is the trust boundary for replica bundles: verify Ed25519 signatures separately from TLS (plan §10, §13).
package signing
import (
"crypto/ed25519"
"evobgp/internal/bundle"
)
// VerifyGzippedTar checks the manifest signature and file SHA-256 entries (delegates to internal/bundle).
func VerifyGzippedTar(raw []byte, pub ed25519.PublicKey) (*bundle.VerifiedContents, error) {
return bundle.VerifyGzippedTar(raw, pub)
}
+28
View File
@@ -0,0 +1,28 @@
package signing
import (
"crypto/ed25519"
"crypto/rand"
"testing"
"evobgp/internal/bundle"
)
func TestVerifyGzippedTarDelegates(t *testing.T) {
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
raw, err := bundle.BuildGzippedTar("r1", "s1", map[string]string{"bird.conf": "router id 192.0.2.1;\n"}, priv)
if err != nil {
t.Fatal(err)
}
pub := priv.Public().(ed25519.PublicKey)
v, err := VerifyGzippedTar(raw, pub)
if err != nil {
t.Fatal(err)
}
if v.Manifest.RevisionID != "r1" {
t.Fatalf("revision %q", v.Manifest.RevisionID)
}
}
+494
View File
@@ -0,0 +1,494 @@
package store
import (
"errors"
"fmt"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/google/uuid"
)
var (
ErrNotFound = errors.New("store: not found")
ErrTenantScope = errors.New("store: tenant mismatch")
ErrInvalidInput = errors.New("store: invalid input")
)
// Memory is a development-oriented in-memory backend for control-plane API handlers.
type Memory struct {
mu sync.RWMutex
tenants map[string]*Tenant
modules map[string]*Module
revisions map[string]*Revision
speakers map[string]*Speaker
// speakerID -> latest published revision for evobgp-node pulls
publishedRevision map[string]publishedInfo
peers map[string]*BGPPeer
// DemoIDs valid after SeedDemo()
demoTenantID string
demoModuleCDN string
demoModuleIP string
demoRevisionID string
demoSpeakerID string
}
type publishedInfo struct {
RevisionID string
PublishedAt time.Time
}
type Tenant struct {
ID string
Name string
Slug string
}
type Module struct {
ID string
TenantID string
Type string // AS_PREFIXES, CDN_CIDRS, DOMAINS, IP_RANGES
Name string
Enabled bool
RefreshIntervalSec int // 0 = unset
CronExpr string // optional cron for scheduler (display / future use)
Priority int
DefaultCommunityID *string
DohProfileID *string
}
type Revision struct {
ID string
TenantID string
ModuleID string
ContentHash string
ParentRevisionID *string
CreatedAt time.Time
// MaterializedPrefixCount is the size of the prefix set for this revision (control-plane / render output).
MaterializedPrefixCount int
// PreviewFragments maps logical paths (e.g. bird.conf) to generated text.
PreviewFragments map[string]string
}
// BGPPeer is a minimal stand-in until SQL-backed bgp_peer is wired into the API.
type BGPPeer struct {
ID string
TenantID string
SpeakerID *string
Name string
Neighbor string
SessionState string // e.g. Established, Idle, Connect (intent or cached ops view)
}
type Speaker struct {
ID string
TenantID string
Role string
Endpoint string
LastAppliedRevisionID *string
}
func NewMemory() *Memory {
return &Memory{
tenants: make(map[string]*Tenant),
modules: make(map[string]*Module),
revisions: make(map[string]*Revision),
speakers: make(map[string]*Speaker),
publishedRevision: make(map[string]publishedInfo),
peers: make(map[string]*BGPPeer),
}
}
// SeedDemo installs a minimal tenant, modules, revision, and speaker for local testing.
func (m *Memory) SeedDemo() {
m.mu.Lock()
defer m.mu.Unlock()
tid := uuid.NewString()
m.tenants[tid] = &Tenant{ID: tid, Name: "Demo", Slug: "demo"}
mCDN := uuid.NewString()
m.modules[mCDN] = &Module{
ID: mCDN,
TenantID: tid,
Type: "CDN_CIDRS",
Name: "demo-cdn",
Enabled: true,
RefreshIntervalSec: 3600,
CronExpr: "",
Priority: 10,
}
mIP := uuid.NewString()
m.modules[mIP] = &Module{
ID: mIP,
TenantID: tid,
Type: "IP_RANGES",
Name: "demo-static",
Enabled: true,
RefreshIntervalSec: 0,
CronExpr: "",
Priority: 20,
}
parent := uuid.NewString()
m.revisions[parent] = &Revision{
ID: parent,
TenantID: tid,
ModuleID: "",
ContentHash: "sha256:parent",
ParentRevisionID: nil,
CreatedAt: time.Now().UTC().Add(-time.Hour),
MaterializedPrefixCount: 0,
PreviewFragments: map[string]string{"bird.conf": "# parent revision\n"},
}
rid := uuid.NewString()
m.revisions[rid] = &Revision{
ID: rid,
TenantID: tid,
ModuleID: "",
ContentHash: "sha256:demo-rev-1",
ParentRevisionID: &parent,
CreatedAt: time.Now().UTC(),
MaterializedPrefixCount: 128,
PreviewFragments: map[string]string{
// Valid minimal BIRD 2 skeleton (see internal/birdfmt/testdata/scenarios/minimal/bird.conf).
"bird.conf": `# EvoBGP demo bundle
router id 192.0.2.1;
protocol device {
}
protocol direct {
ipv4;
ipv6;
}
`,
"bird.d/evobgp_demo.conf": "# static demo fragment\n",
},
}
sid := uuid.NewString()
m.speakers[sid] = &Speaker{
ID: sid,
TenantID: tid,
Role: "replica",
Endpoint: "10.0.0.2:179",
LastAppliedRevisionID: nil,
}
m.publishedRevision[sid] = publishedInfo{RevisionID: rid, PublishedAt: time.Now().UTC()}
p1 := uuid.NewString()
m.peers[p1] = &BGPPeer{
ID: p1,
TenantID: tid,
SpeakerID: &sid,
Name: "demo-upstream-4",
Neighbor: "198.51.100.2",
SessionState: "Established",
}
p2 := uuid.NewString()
m.peers[p2] = &BGPPeer{
ID: p2,
TenantID: tid,
SpeakerID: &sid,
Name: "demo-upstream-6",
Neighbor: "2001:db8::2",
SessionState: "Idle",
}
m.demoTenantID, m.demoModuleCDN, m.demoModuleIP, m.demoRevisionID, m.demoSpeakerID = tid, mCDN, mIP, rid, sid
}
// MaterializedPrefixStats returns max and sum of MaterializedPrefixCount across revisions.
func (m *Memory) MaterializedPrefixStats() (max int, sum int) {
m.mu.RLock()
defer m.mu.RUnlock()
for _, r := range m.revisions {
sum += r.MaterializedPrefixCount
if r.MaterializedPrefixCount > max {
max = r.MaterializedPrefixCount
}
}
return max, sum
}
// PeerCount returns the number of configured BGP peers.
func (m *Memory) PeerCount() int {
m.mu.RLock()
defer m.mu.RUnlock()
return len(m.peers)
}
// PeerSessionCountsByState returns peer counts grouped by SessionState.
func (m *Memory) PeerSessionCountsByState() map[string]int {
m.mu.RLock()
defer m.mu.RUnlock()
out := make(map[string]int)
for _, p := range m.peers {
st := strings.TrimSpace(p.SessionState)
if st == "" {
st = "unknown"
}
out[st]++
}
return out
}
// DemoIDs returns IDs from SeedDemo; empty strings if SeedDemo was not called.
func (m *Memory) DemoIDs() (tenant, moduleCDN, moduleIP, revision, speaker string) {
m.mu.RLock()
defer m.mu.RUnlock()
return m.demoTenantID, m.demoModuleCDN, m.demoModuleIP, m.demoRevisionID, m.demoSpeakerID
}
// ListModules returns modules for a tenant (sorted by priority, then name).
func (m *Memory) ListModules(tenantID string) []*Module {
m.mu.RLock()
defer m.mu.RUnlock()
var out []*Module
for _, mod := range m.modules {
if mod.TenantID == tenantID {
out = append(out, mod)
}
}
sort.Slice(out, func(i, j int) bool {
if out[i].Priority != out[j].Priority {
return out[i].Priority < out[j].Priority
}
return out[i].Name < out[j].Name
})
return out
}
// ListPeers returns BGP peers for a tenant (sorted by name).
func (m *Memory) ListPeers(tenantID string) []*BGPPeer {
m.mu.RLock()
defer m.mu.RUnlock()
var out []*BGPPeer
for _, p := range m.peers {
if p.TenantID == tenantID {
out = append(out, p)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
func (m *Memory) GetModule(tenantID, moduleID string) (*Module, error) {
m.mu.RLock()
defer m.mu.RUnlock()
mod, ok := m.modules[moduleID]
if !ok {
return nil, ErrNotFound
}
if mod.TenantID != tenantID {
return nil, ErrTenantScope
}
return mod, nil
}
func (m *Memory) GetRevision(tenantID, revisionID string) (*Revision, error) {
m.mu.RLock()
defer m.mu.RUnlock()
rev, ok := m.revisions[revisionID]
if !ok {
return nil, ErrNotFound
}
if rev.TenantID != tenantID {
return nil, ErrTenantScope
}
return rev, nil
}
func (m *Memory) GetSpeaker(tenantID, speakerID string) (*Speaker, error) {
m.mu.RLock()
defer m.mu.RUnlock()
sp, ok := m.speakers[speakerID]
if !ok {
return nil, ErrNotFound
}
if sp.TenantID != tenantID {
return nil, ErrTenantScope
}
return sp, nil
}
// ListSpeakersForTenant returns speakers for a tenant (IDs sorted) for deploy-all and similar operations.
func (m *Memory) ListSpeakersForTenant(tenantID string) []*Speaker {
m.mu.RLock()
defer m.mu.RUnlock()
var ids []string
for id, sp := range m.speakers {
if sp.TenantID == tenantID {
ids = append(ids, id)
}
}
sort.Strings(ids)
out := make([]*Speaker, 0, len(ids))
for _, id := range ids {
out = append(out, m.speakers[id])
}
return out
}
// GetSpeakerAnyTenant resolves a speaker without tenant check (node API uses speaker id in path).
func (m *Memory) GetSpeakerAnyTenant(speakerID string) (*Speaker, error) {
m.mu.RLock()
defer m.mu.RUnlock()
sp, ok := m.speakers[speakerID]
if !ok {
return nil, ErrNotFound
}
return sp, nil
}
func (m *Memory) LatestPublishedRevision(speakerID string) (revisionID string, publishedAt time.Time, err error) {
m.mu.RLock()
defer m.mu.RUnlock()
info, ok := m.publishedRevision[speakerID]
if !ok {
return "", time.Time{}, ErrNotFound
}
return info.RevisionID, info.PublishedAt, nil
}
// CreateRollbackRevision adds a new revision that reuses content from sourceRevisionID (same hash & preview copy).
func (m *Memory) CreateRollbackRevision(tenantID, sourceRevisionID string) (newID string, err error) {
m.mu.Lock()
defer m.mu.Unlock()
src, ok := m.revisions[sourceRevisionID]
if !ok {
return "", ErrNotFound
}
if src.TenantID != tenantID {
return "", ErrTenantScope
}
newID = uuid.NewString()
frag := make(map[string]string, len(src.PreviewFragments))
for k, v := range src.PreviewFragments {
frag[k] = v
}
parent := sourceRevisionID
m.revisions[newID] = &Revision{
ID: newID,
TenantID: tenantID,
ModuleID: src.ModuleID,
ContentHash: src.ContentHash + ":rollback",
ParentRevisionID: &parent,
CreatedAt: time.Now().UTC(),
MaterializedPrefixCount: src.MaterializedPrefixCount,
PreviewFragments: frag,
}
return newID, nil
}
// SetLastAppliedRevision updates speaker state after deploy job (demo).
func (m *Memory) SetLastAppliedRevision(tenantID, speakerID, revisionID string) error {
m.mu.Lock()
defer m.mu.Unlock()
sp, ok := m.speakers[speakerID]
if !ok {
return ErrNotFound
}
if sp.TenantID != tenantID {
return ErrTenantScope
}
if _, ok := m.revisions[revisionID]; !ok {
return fmt.Errorf("%w: revision", ErrNotFound)
}
sp.LastAppliedRevisionID = &revisionID
return nil
}
// PublishRevisionForSpeaker marks latest bundle pointer for a replica speaker.
func (m *Memory) PublishRevisionForSpeaker(speakerID, revisionID string) error {
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.speakers[speakerID]; !ok {
return ErrNotFound
}
if _, ok := m.revisions[revisionID]; !ok {
return fmt.Errorf("%w: revision", ErrNotFound)
}
m.publishedRevision[speakerID] = publishedInfo{RevisionID: revisionID, PublishedAt: time.Now().UTC()}
return nil
}
// RevisionDiff returns a trivial JSON-friendly diff between prefix sets (empty for memory demo).
func (m *Memory) RevisionDiff(tenantID, aID, bID string) (map[string]any, error) {
_, err := m.getRevisionLocked(tenantID, aID)
if err != nil {
return nil, err
}
_, err = m.getRevisionLocked(tenantID, bID)
if err != nil {
return nil, err
}
return map[string]any{
"revision_a": aID,
"revision_b": bID,
"prefixes": map[string]any{
"added": []string{},
"removed": []string{},
"unchanged_count": 0,
},
}, nil
}
func (m *Memory) getRevisionLocked(tenantID, revisionID string) (*Revision, error) {
rev, ok := m.revisions[revisionID]
if !ok {
return nil, ErrNotFound
}
if rev.TenantID != tenantID {
return nil, ErrTenantScope
}
return rev, nil
}
// ListRevisions returns recent revisions for tenant (newest first), cursor is opaque offset string.
func (m *Memory) ListRevisions(tenantID, moduleID string, cursor string, limit int) (items []*Revision, nextCursor string, hasMore bool) {
if limit <= 0 {
limit = 50
}
m.mu.RLock()
defer m.mu.RUnlock()
var all []*Revision
for _, r := range m.revisions {
if r.TenantID != tenantID {
continue
}
if moduleID != "" && r.ModuleID != moduleID {
continue
}
all = append(all, r)
}
sort.Slice(all, func(i, j int) bool {
return all[i].CreatedAt.After(all[j].CreatedAt)
})
off := 0
if cursor != "" {
if n, err := strconv.Atoi(cursor); err == nil && n >= 0 {
off = n
}
}
end := off + limit
if end > len(all) {
end = len(all)
}
page := all[off:end]
if end < len(all) {
nextCursor = fmt.Sprintf("%d", end)
hasMore = true
}
return page, nextCursor, hasMore
}