Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ _test
dist/
.idea/
.vscode/
.claude/
.vs/
.DS_Store
tmp/
Expand Down
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ The big-picture layers (each is one or more packages under `pkg/`):
- **Config (`pkg/config/`)** — parses the YAML config dir (`clusters`, `node-groups`, `bee-configs`, `checks`, `simulations`), resolves `_inherit` inheritance, and exports into the `orchestration.*Options` types. Read from a local dir or a Git repo (`config-git-repo`).
- **Orchestration (`pkg/orchestration/`)** — backend-agnostic `Cluster` → `NodeGroup` → `Node` model. The `k8s/` backend translates it into Kubernetes resources; `notset/` is the no-op fallback when K8s is disabled.
- **Checks (`pkg/check/`)** — each check implements the `beekeeper.Action` interface and is registered by name in the `Checks` map in `pkg/config/check.go`; `pkg/check/runner.go` resolves names to implementations and runs them.
- **Clients** — `pkg/bee/` (+ `pkg/bee/api/`) is the HTTP client for a running Bee node; `pkg/k8s/` wraps the Kubernetes client (with fakes under `pkg/k8s/mocks/`); `pkg/swap/` is the Geth/blockchain client.
- **Clients** — `pkg/bee/` (+ `pkg/bee/api/`) is the HTTP client for a running Bee node; `pkg/k8s/` wraps the Kubernetes client (tested with client-go's fake clientset; `pkg/k8s/mocks/` holds only the `ClientConfig`/RoundTripper doubles); `pkg/swap/` is the Geth/blockchain client.
- **Operational packages** — `stamper`, `nuker`, `restart`, `funder` act on already-running nodes and discover them through `pkg/node/` (`NodeProvider`: Beekeeper cluster, namespace+label, or Helm), not the orchestration layer.

## Deployment / operating modes
Expand All @@ -58,7 +58,7 @@ Commands work against clusters provisioned in three different ways — know whic

- **Code style**: prefer clear, idiomatic Go that follows standard best practices and principles — small focused functions, explicit error wrapping with context, no premature abstraction. Keep changes minimal and consistent with the surrounding code.
- **Commits & PR titles**: Conventional Commits, lowercase type, no trailing period, `feat(scope): …` style (enforced by `commitlint.config.js`). Do not push commits; when a commit message is requested, use the subject line only — no body/description.
- **Tests**: prefer external test packages (`package foo_test`); use the `pkg/k8s/mocks` fakes instead of a live cluster. The race detector must pass.
- **Tests**: prefer external test packages (`package foo_test`). Test the `pkg/k8s` clients against client-go's fake clientset instead of a live cluster — `fake.NewClientset()` with `PrependReactor` for error paths, and `watch.NewRaceFreeFake` + `PrependWatchReactor` for watch paths (buffer/drive events, bound tests with `context.WithTimeout`, never `time.Sleep`). The only hand-written mock left is `pkg/k8s/mocks` (the `ClientConfig`/`RoundTripper` doubles backing `pkg/k8s/k8s_test.go`). The race detector must pass.
- **Dependencies**: do not add or bump modules unless the task requires it (Dependabot handles routine bumps).
- **Linting**: `gofmt` + `gofumpt` formatting and the linters in `.golangci.yml` (errorlint, errname, nilerr, goconst, misspell, unconvert, copyloopvar) must pass. Note this repo does **not** use BSD copyright file headers — don't add them.

Expand Down
33 changes: 19 additions & 14 deletions pkg/k8s/configmap/configmap_test.go
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
package configmap_test

import (
"context"
"errors"
"fmt"
"reflect"
"testing"

"github.com/ethersphere/beekeeper/pkg/k8s/configmap"
"github.com/ethersphere/beekeeper/pkg/k8s/mocks"
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/kubernetes/fake"

"github.com/ethersphere/beekeeper/pkg/k8s/configmap"
"github.com/ethersphere/beekeeper/pkg/k8s/internal/k8stest"
)

func TestSet(t *testing.T) {
t.Parallel()
testTable := []struct {
name string
configName string
Expand Down Expand Up @@ -53,22 +55,24 @@ func TestSet(t *testing.T) {
},
{
name: "create_error",
configName: mocks.CreateBad,
clientset: mocks.NewClientset(),
errorMsg: fmt.Errorf("creating configmap create_bad in namespace test: mock error: cannot create config map"),
configName: "test_config_map",
// No object seeded, so Update returns NotFound and Set falls through
// to Create, which the reactor fails.
clientset: k8stest.NewErrorClientset("create", "configmaps", errors.New("mock error: cannot create config map")),
errorMsg: fmt.Errorf("creating configmap test_config_map in namespace test: mock error: cannot create config map"),
},
{
name: "update_error",
configName: mocks.UpdateBad,
clientset: mocks.NewClientset(),
errorMsg: fmt.Errorf("updating configmap update_bad in namespace test: mock error: cannot update config map"),
configName: "test_config_map",
clientset: k8stest.NewErrorClientset("update", "configmaps", errors.New("mock error: cannot update config map")),
errorMsg: fmt.Errorf("updating configmap test_config_map in namespace test: mock error: cannot update config map"),
},
}

for _, test := range testTable {
t.Run(test.name, func(t *testing.T) {
client := configmap.NewClient(test.clientset)
response, err := client.Set(context.Background(), test.configName, "test", test.options)
response, err := client.Set(t.Context(), test.configName, "test", test.options)
if test.errorMsg == nil {
if err != nil {
t.Errorf("error not expected, got: %s", err.Error())
Expand Down Expand Up @@ -108,6 +112,7 @@ func TestSet(t *testing.T) {
}

func TestDelete(t *testing.T) {
t.Parallel()
testTable := []struct {
name string
configName string
Expand Down Expand Up @@ -136,16 +141,16 @@ func TestDelete(t *testing.T) {
},
{
name: "delete_error",
configName: mocks.DeleteBad,
clientset: mocks.NewClientset(),
errorMsg: fmt.Errorf("deleting configmap delete_bad in namespace test: mock error: cannot delete config map"),
configName: "test_config_map",
clientset: k8stest.NewErrorClientset("delete", "configmaps", errors.New("mock error: cannot delete config map")),
errorMsg: fmt.Errorf("deleting configmap test_config_map in namespace test: mock error: cannot delete config map"),
},
}

for _, test := range testTable {
t.Run(test.name, func(t *testing.T) {
client := configmap.NewClient(test.clientset)
err := client.Delete(context.Background(), test.configName, "test")
err := client.Delete(t.Context(), test.configName, "test")
if test.errorMsg == nil {
if err != nil {
t.Errorf("error not expected, got: %s", err.Error())
Expand Down
172 changes: 172 additions & 0 deletions pkg/k8s/customresource/ingressroute/client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
package ingressroute_test

import (
"errors"
"io"
"reflect"
"sort"
"testing"

"github.com/ethersphere/beekeeper/pkg/k8s/customresource/ingressroute"
"github.com/ethersphere/beekeeper/pkg/k8s/customresource/ingressroute/mock"
"github.com/ethersphere/beekeeper/pkg/k8s/ingress"
"github.com/ethersphere/beekeeper/pkg/logging"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
)

// testIRName is the canonical IngressRoute name used across the ingressroute
// tests.
const testIRName = "ir-0"

func newClient(opts ...mock.Option) *ingressroute.Client {
return ingressroute.NewClient(mock.New(opts...), logging.New(io.Discard, 0))
}

// newIR builds an IngressRoute in namespace "test" with one Route per match.
func newIR(name string, matches ...string) ingressroute.IngressRoute {
ir := ingressroute.IngressRoute{
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "test"},
}
for _, m := range matches {
ir.Spec.Routes = append(ir.Spec.Routes, ingressroute.Route{Match: m})
}
return ir
}

func TestClientSet(t *testing.T) {
t.Parallel()
t.Run("create_when_not_found", func(t *testing.T) {
client := newClient()
ing, err := client.Set(t.Context(), testIRName, "test", ingressroute.Options{})
if err != nil {
t.Fatalf("error not expected, got: %s", err.Error())
}
if ing == nil || ing.Name != testIRName {
t.Errorf("expected created ingress route ir-0, got: %#v", ing)
}
})

t.Run("update_when_found", func(t *testing.T) {
client := newClient(mock.WithIngressRoutes(newIR(testIRName)))
ing, err := client.Set(t.Context(), testIRName, "test", ingressroute.Options{})
if err != nil {
t.Fatalf("error not expected, got: %s", err.Error())
}
if ing == nil || ing.Name != testIRName {
t.Errorf("expected updated ingress route ir-0, got: %#v", ing)
}
})

t.Run("get_error", func(t *testing.T) {
client := newClient(mock.WithGetError(errors.New("mock error")))
_, err := client.Set(t.Context(), testIRName, "test", ingressroute.Options{})
if err == nil || err.Error() != "getting ingress route ir-0 in namespace test: mock error" {
t.Errorf("unexpected error: %v", err)
}
})

t.Run("create_error", func(t *testing.T) {
client := newClient(mock.WithCreateError(errors.New("mock error")))
_, err := client.Set(t.Context(), testIRName, "test", ingressroute.Options{})
if err == nil || err.Error() != "creating ingress route ir-0 in namespace test: mock error" {
t.Errorf("unexpected error: %v", err)
}
})

t.Run("update_error", func(t *testing.T) {
client := newClient(mock.WithIngressRoutes(newIR(testIRName)), mock.WithUpdateError(errors.New("mock error")))
_, err := client.Set(t.Context(), testIRName, "test", ingressroute.Options{})
if err == nil || err.Error() != "updating ingress route ir-0 in namespace test: mock error" {
t.Errorf("unexpected error: %v", err)
}
})
}

func TestClientDelete(t *testing.T) {
t.Parallel()
t.Run("delete_existing", func(t *testing.T) {
client := newClient(mock.WithIngressRoutes(newIR(testIRName)))
if err := client.Delete(t.Context(), testIRName, "test"); err != nil {
t.Errorf("error not expected, got: %s", err.Error())
}
})

t.Run("delete_not_found_is_nil", func(t *testing.T) {
client := newClient()
if err := client.Delete(t.Context(), testIRName, "test"); err != nil {
t.Errorf("not-found delete should be nil, got: %s", err.Error())
}
})

t.Run("delete_error", func(t *testing.T) {
client := newClient(mock.WithDeleteError(errors.New("mock error")))
err := client.Delete(t.Context(), testIRName, "test")
if err == nil || err.Error() != "deleting ingress route ir-0 in namespace test: mock error" {
t.Errorf("unexpected error: %v", err)
}
})
}

func TestClientGetNodes(t *testing.T) {
t.Parallel()
sortNodes := func(nodes []ingress.NodeInfo) {
sort.Slice(nodes, func(i, j int) bool {
if nodes[i].Name != nodes[j].Name {
return nodes[i].Name < nodes[j].Name
}
return nodes[i].Host < nodes[j].Host
})
}

t.Run("extracts_hosts", func(t *testing.T) {
client := newClient(mock.WithIngressRoutes(
// the PathPrefix route has no Host(...) → GetHost returns "" → skipped
newIR(testIRName, `Host("a.example.com")`, "PathPrefix(`/x`)"),
newIR("ir-1", `Host("b.example.com")`),
))
nodes, err := client.GetNodes(t.Context(), "test", "")
if err != nil {
t.Fatalf("error not expected, got: %s", err.Error())
}
sortNodes(nodes)
expected := []ingress.NodeInfo{
{Name: testIRName, Host: "a.example.com"},
{Name: "ir-1", Host: "b.example.com"},
}
if !reflect.DeepEqual(nodes, expected) {
t.Errorf("nodes expected: %#v, got: %#v", expected, nodes)
}
})

t.Run("no_routes", func(t *testing.T) {
client := newClient()
nodes, err := client.GetNodes(t.Context(), "test", "")
if err != nil {
t.Fatalf("error not expected, got: %s", err.Error())
}
if nodes != nil {
t.Errorf("nodes expected nil, got: %#v", nodes)
}
})

t.Run("list_not_found_is_nil", func(t *testing.T) {
client := newClient(mock.WithListError(apierrors.NewNotFound(schema.GroupResource{}, "")))
nodes, err := client.GetNodes(t.Context(), "test", "")
if err != nil {
t.Errorf("not-found list should be nil error, got: %s", err.Error())
}
if nodes != nil {
t.Errorf("nodes expected nil, got: %#v", nodes)
}
})

t.Run("list_error", func(t *testing.T) {
client := newClient(mock.WithListError(errors.New("mock error")))
_, err := client.GetNodes(t.Context(), "test", "")
if err == nil || err.Error() != "list ingress routes in namespace test: mock error" {
t.Errorf("unexpected error: %v", err)
}
})
}
111 changes: 111 additions & 0 deletions pkg/k8s/customresource/ingressroute/ingressroute_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package ingressroute_test

import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/ethersphere/beekeeper/pkg/k8s/customresource/ingressroute"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/rest"
)

// TestRESTClient exercises the real REST layer (config.NewForConfig +
// ingressRouteClient CRUD/Watch) against an httptest.Server. httptest is used
// instead of client-go's fake RESTClient so the custom Traefik scheme is wired
// up by NewForConfig itself (no manual serializer/scheme scaffolding).
func TestRESTClient(t *testing.T) {
t.Parallel()
apiVersion := ingressroute.GroupName + "/" + ingressroute.GroupVersion

ir := ingressroute.IngressRoute{
TypeMeta: metav1.TypeMeta{APIVersion: apiVersion, Kind: "IngressRoute"},
ObjectMeta: metav1.ObjectMeta{Name: testIRName, Namespace: "test"},
Spec: ingressroute.IngressRouteSpec{Routes: []ingressroute.Route{{Match: `Host("x.example.com")`}}},
}
irList := ingressroute.IngressRouteList{
TypeMeta: metav1.TypeMeta{APIVersion: apiVersion, Kind: "IngressRouteList"},
Items: []ingressroute.IngressRoute{ir},
}

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodDelete:
_ = json.NewEncoder(w).Encode(&metav1.Status{
TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Status"},
Status: metav1.StatusSuccess,
})
case r.Method == http.MethodGet && r.URL.Query().Get("watch") == "true":
w.WriteHeader(http.StatusOK) // empty watch stream
case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/ingressroutes"):
_ = json.NewEncoder(w).Encode(&irList)
default: // GET by name, POST (create), PUT (update)
_ = json.NewEncoder(w).Encode(&ir)
}
}))
defer server.Close()

client, err := ingressroute.NewForConfig(&rest.Config{Host: server.URL})
if err != nil {
t.Fatalf("NewForConfig: %s", err.Error())
}
irs := client.IngressRoutes("test")
ctx := t.Context()

t.Run("Get", func(t *testing.T) {
got, err := irs.Get(ctx, testIRName, metav1.GetOptions{})
if err != nil {
t.Fatalf("Get: %s", err.Error())
}
if got.Name != testIRName {
t.Errorf("expected name ir-0, got: %q", got.Name)
}
})

t.Run("List", func(t *testing.T) {
list, err := irs.List(ctx, metav1.ListOptions{})
if err != nil {
t.Fatalf("List: %s", err.Error())
}
if len(list.Items) != 1 {
t.Errorf("expected 1 item, got: %d", len(list.Items))
}
})

t.Run("Create", func(t *testing.T) {
got, err := irs.Create(ctx, &ir)
if err != nil {
t.Fatalf("Create: %s", err.Error())
}
if got.Name != testIRName {
t.Errorf("expected name ir-0, got: %q", got.Name)
}
})

t.Run("Update", func(t *testing.T) {
got, err := irs.Update(ctx, &ir, metav1.UpdateOptions{})
if err != nil {
t.Fatalf("Update: %s", err.Error())
}
if got.Name != testIRName {
t.Errorf("expected name ir-0, got: %q", got.Name)
}
})

t.Run("Delete", func(t *testing.T) {
if err := irs.Delete(ctx, testIRName, metav1.DeleteOptions{}); err != nil {
t.Errorf("Delete: %s", err.Error())
}
})

t.Run("Watch", func(t *testing.T) {
watcher, err := irs.Watch(ctx, metav1.ListOptions{})
if err != nil {
t.Fatalf("Watch: %s", err.Error())
}
watcher.Stop()
})
}
Loading
Loading