This document defines the coding standards, naming rules, error handling patterns, testing conventions, and idioms enforced across this codebase. All contributors must follow these rules. Automated checks (golangci-lint, flake8) enforce many of them; the rest are enforced in code review.
- Module:
go.datum.net/galactic cmd/galactic/main.go— binary entry point; all Cobra subcommands registered herepkg/apis/v1alpha/— public CRD types; only add types here that are part of the Kubernetes API surfacepkg/common/— utilities shared between operator, agent, and CNIpkg/proto/local//pkg/proto/remote/— gRPC / protobuf generated files plus hand-written convenience wrappersinternal/operator/— operator reconcilers, identifier logic, CNI config generation, webhooksinternal/agent/srv6/— kernel SRv6 route and VRF managementinternal/cni/— CNI plugin (cmdAdd / cmdDel implementation)internal/cmd/— one sub-package per Cobra subcommand (agent,cni,operator,version)
Place new code in internal/ unless it must be imported by an external caller. Prefer creating a focused sub-package over adding to an existing large one.
Use three groups, separated by blank lines:
- Standard library
- Third-party and Kubernetes packages
- Internal packages (
go.datum.net/galactic/...)
goimports enforces this automatically. Alias imports use the last meaningful path segment as the short name (ctrl, metav1, corev1, nadv1, galacticv1alpha).
import (
"context"
"fmt"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
galacticv1alpha "go.datum.net/galactic/pkg/apis/v1alpha"
"go.datum.net/galactic/internal/operator/identifier"
)| Element | Convention | Example |
|---|---|---|
| Package | lowercase, single word, matches directory | package controller |
| Exported type | CapWords |
VPCAttachmentReconciler |
| Exported function / method | CamelCase |
RouteIngressAdd |
| Unexported function / method | camelCase |
vpcAttachmentsToIdentifiers |
| Exported constant | CamelCase, descriptive |
MaxVPC, MaxIdentifierAttemptsVPCAttachment |
| Test package | <name>_test |
package identifier_test |
| K8s API import aliases | domain-prefixed group-version | galacticv1alpha, nadv1, metav1 |
Do not use _ in Go identifiers except in test package names.
- Wrap errors with context using
fmt.Errorf("what failed: %w", err). The context string must complete the sentence "could not<what>". - Collect multiple independent errors with
errors.Join(errs...)and return the joined error. - In controllers, return
ctrl.Result{}, client.IgnoreNotFound(err)on the initial resource fetch (expected not-found on deletion). - Return
ctrl.Result{RequeueAfter: duration}, nilwhen waiting for a dependency (e.g., VPC not yet ready). Do not return an error for expected transient states. - Never swallow errors silently. If an error truly cannot be actionable, log it before discarding.
- Do not add error handling for scenarios that the code guarantees cannot happen.
// correct
if err := r.Get(ctx, req.NamespacedName, &vpc); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
// correct — multiple independent operations
var errs []error
if err := neighborproxy.Add(...); err != nil {
errs = append(errs, fmt.Errorf("neighborproxy add failed: %w", err))
}
if err := routeegress.Add(...); err != nil {
errs = append(errs, fmt.Errorf("routeegress add failed: %w", err))
}
return errors.Join(errs...)Define sentinel / limit values as typed or untyped constants at package level. Use hex notation for bit-mask constants:
const MaxVPC uint64 = 0xFFFFFFFFFFFF
const MaxVPCAttachment uint64 = 0xFFFF- Reconciler struct embeds
client.ClientandScheme *runtime.Scheme; add service dependencies as named pointer fields. - All state assignments must be idempotent — guard with conditions before mutating:
if vpcAttachment.Status.Identifier == "" { // assign once }
- Use
controllerutil.CreateOrUpdatefor Kubernetes resources the reconciler manages. - Always call
controllerutil.SetControllerReferenceto wire ownership for garbage collection. - Named the controller in
SetupWithManagerusing.Named("lowercase-kind").
Every field in an API type must have either // +required or // +optional. Required fields must not carry omitempty. Optional fields must carry omitempty. Embedded structs (TypeMeta, ObjectMeta) carry both omitempty and omitzero.
// +required
Spec VPCSpec `json:"spec"`
// +optional
Status VPCStatus `json:"status,omitempty,omitzero"`RBAC markers live on the Reconcile method of each controller, not on the struct.
Generated files (zz_generated.deepcopy.go, CRD YAML in config/crd/bases/, protobuf *.pb.go) must never be hand-edited. After any change to API types:
make generate # regenerates DeepCopy methods
make manifests # regenerates CRD YAML and RBAC
Both commands must be run together. Generated files are committed to version control.
The boilerplate license header (hack/boilerplate.go.txt) is injected automatically by controller-gen.
Run make lint before every PR. All linters listed in .golangci.yml must pass. Suppressions require a comment explaining why. Notable active linters: errcheck, staticcheck, govet, revive, gocyclo, dupl, unused, ginkgolinter.
Exclusions by path:
lllis excluded fromapi/*andinternal/*duplis excluded frominternal/*- The
lab/directory is entirely excluded
Requires Python ≥ 3.13. Do not use compatibility shims for older versions.
| Element | Convention |
|---|---|
| Module / file | snake_case |
| Function / method | snake_case |
| Variable | snake_case |
| Class | CapWords |
| Constant (module level) | UPPER_SNAKE_CASE |
- 4-space indent; no tabs.
flake8withwemake-python-styleguideenforces all style rules. Run fromrouter/:flake8 .- Suppress only specific codes with inline
# noqa: WPSxxxcomments at the end of the offending line. Do not use bare# noqa. - Common suppressions used in this codebase:
# noqa: WPS211,WPS216— many arguments in CLI entry-point functions# noqa: WPS457—while True:event loops# noqa: WPS432— magic hex bit-mask constants# noqa: WPS214,WPS338— classes with many methods / method overrides
Use type hints on all function signatures. Use -> return type annotations. Use pydantic / sqlmodel type-annotated models instead of plain dicts for structured data.
async def handle_register(self, event: RegisterEvent) -> bool:- Service classes implement an async
run()coroutine. - Concurrent service startup uses
asyncio.TaskGroup:async with asyncio.TaskGroup() as tg: for service in services: tg.create_task(service)
- Top-level entry via
aiorun.run(...)withstop_on_unhandled_errors=True. - Event dispatch via
bubusEventBus; handlers receive typed event objects (RegisterEvent,DeregisterEvent,RouteEvent).
Elevate pure functions that do not access self to @staticmethod:
def extract_vpc_from_srv6_endpoint(endpoint: str) -> tuple[str, str]:
...- Use
sqlmodelwith SQLAlchemy for ORM. - Use
Sessionas a context manager:with Session(engine) as session:. - All schema changes go through Alembic migrations in
router/alembic/versions/. Never modify tables outside of migrations. - Migrations are not auto-applied at startup by default; run
alembic upgrade headbefore deploying a new router version.
Use the standard logging module. Obtain a logger per module: logger = logging.getLogger("ModuleName"). Use logger.info, logger.warning, logger.error. Prefer structured f-strings in log messages.
Use the standard testing package. Follow table-driven test style:
tests := []struct {
name string
value uint64
wantIdentifier string
wantError bool
}{
{"InvalidSpecialMin", 0, "", true},
{"Valid", 12345, "000000003039", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := id.FromValue(tt.value, identifier.MaxVPC)
if (err != nil) != tt.wantError {
t.Errorf("FromValue() error = %v, wantError = %v", err, tt.wantError)
}
if got != tt.wantIdentifier {
t.Errorf("FromValue() got = %v, want = %v", got, tt.wantIdentifier)
}
})
}- Test package name:
package <name>_test(external test package). - Name test cases using
UpperCamelCase(e.g.,"ValidMin","InvalidSpecialMax"). - Use
wantError boolto test error presence; do not test error message strings unless the message is part of the contract.
- Dot-import both
ginkgo/v2andgomegain controller test files. - Each controller has its own
_test.gofile and shares asuite_test.goin the same package. - Suite bootstrap in
suite_test.go: oneTestXxx(t *testing.T)function that callsRegisterFailHandler(Fail)thenRunSpecs(t, "Suite Name"). - envtest provides a real Kubernetes API server. CRDs are loaded from
config/crd/bases. Runmake setup-envtestbefore running tests. - Annotate test steps with
By("description")to produce readable output on failure. - Lifecycle:
BeforeEachfor setup,AfterEachfor cleanup; always clean up created objects. - Assert status fields are falsy/empty immediately after creation before reconciliation:
Expect(resource.Status.Ready).To(BeFalse()) Expect(resource.Status.Identifier).To(BeEmpty())
- Feature files live in
router/features/using Gherkin syntax (Feature:,Scenario:,Given/When/Then). - Step definitions live in
router/features/steps/. - Step functions are async:
async def step_xxx(context, ...). - Use
deepdiff.DeepDiffwithignore_order=Truefor comparing unordered collections of route events. - Run:
behavefromrouter/.
- Do not mock the Kubernetes API server in controller tests — use envtest.
- Do not write tests for generated code (
zz_generated.deepcopy.go,*.pb.go). - Agent and CNI kernel-path code (
internal/agent/srv6/,internal/cni/) currently has no unit coverage; new code in those paths should prefer integration/e2e over fragile mock-heavy unit tests.
.protofiles live inpkg/proto/local/(CNI-to-agent local gRPC) andpkg/proto/remote/(agent-to-router MQTT).- Generated
*.pb.go/*_grpc.pb.gofiles must never be hand-edited. - Each proto package has a hand-written convenience wrapper (
local.go,remote.go) that exposes a cleaner Go API over the generated types. Add helpers there rather than importing generated types directly in application code.
config/uses Kustomize.config/default/is the base overlay for production deployment.- CRD manifests in
config/crd/bases/are generated — never edit by hand. - RBAC roles are generated from kubebuilder markers — never edit
config/rbac/role.yamlby hand. config/samples/contains working examples of VPC, VPCAttachment, and annotated Pod; keep samples up to date with API changes.
Use imperative mood, sentence case, present tense. First line ≤ 72 characters. Reference issues where applicable.
Add SRv6 egress route cleanup on VPCAttachment deletion
Fixes #42
Before opening a pull request, run all of the following and ensure they pass:
# Go
make lint test
# Python (if router changed)
cd router && flake8 . && behavee2e tests (make test-e2e) run only on main and release branches, not on PRs. Do not rely on them to catch regressions — write unit or integration tests instead.