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.
This commit is contained in:
1 parent
272542b92a
commit
bf52b21150
131 files changed
+9222
-17
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ func TestBirdScenarioPaths_Table(t *testing.T) {
|
||||
"large_prefix_list",
|
||||
"minimal",
|
||||
"mixed_static_bgp",
|
||||
"standard_layout",
|
||||
"static_ipv4",
|
||||
"with_include",
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
filter evobgp_export_v4 {
|
||||
if net ~ [ 203.0.113.0/24 ] then accept;
|
||||
reject;
|
||||
}
|
||||
+3
@@ -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;
|
||||
};
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
protocol static evobgp_prefixes_v4 {
|
||||
ipv4;
|
||||
route 203.0.113.0/24 unreachable;
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
protocol static evobgp_prefixes_v6 {
|
||||
ipv6;
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package jobs
|
||||
|
||||
import "errors"
|
||||
|
||||
var ErrNotFound = errors.New("jobs: not found")
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user