Go for this codebase¶
This chapter teaches the Go you need to read and change rendimiento, using its own code as the examples. If you know another language (Python, JavaScript, Java), you will recognise most ideas; the differences are what this chapter is about.
Why Go here¶
- It is the language of Kubernetes. The Kubernetes client (
client-go), controller-runtime, Helm and kustomize are Go libraries. rendimiento imports them: it renders Helm charts with Helm's own code and kustomizations with kustomize's, instead of shelling out to tools. - One static binary.
go buildproduces a single executable with no runtime to install (no JVM, no Node, no Python). The container image is that file on an otherwise empty base. - Easy concurrency. Goroutines and channels make "run these steps in parallel, at most four at a time" a few lines.
- Simple, explicit, fast to compile. Few features, strict formatting (
gofmt), and errors as values make code easy to read months later, and easy for newcomers to contribute to.
Modules and packages¶
A module is a versioned project: go.mod at the root names it (github.com/p0dxD/rendimiento.ai) and lists its dependencies with exact versions (go.sum holds their checksums). go mod tidy keeps both in order.
A package is a folder of .go files that share a package name. Each file begins with it and its imports:
package controller
import (
"context" // standard library
appsv1 "k8s.io/api/apps/v1" // a dependency, renamed
"github.com/p0dxD/rendimiento.ai/internal/render" // another package of ours
)
internal/is special: packages under it can only be imported from within this module. Everything exceptapi/v1alpha1lives there, so the code can change freely without breaking outsiders.cmd/rendimientohaspackage mainandfunc main(): the program's entry point.- Capitalisation is visibility.
Render(capital) is exported and usable from other packages;render(lowercase) is private to its package. There are nopublic/privatekeywords.
Types: structs and methods¶
A struct groups fields. JSON and YAML names come from struct tags:
type Health struct {
Path string `json:"path,omitempty"` // HTTP check
TCP bool `json:"tcp,omitempty"` // TCP check
Timeout int `json:"timeout,omitempty"` // seconds
}
Methods are functions with a receiver. A pointer receiver (*Spec) can modify the value; a value receiver works on a copy:
func (s *Spec) Default() { /* fills in defaults in place */ }
func (s Size) Resources() Resources { return sizes[s] }
There are no classes and no inheritance. Behaviour is attached to types with methods, and shared through interfaces and composition (embedding one struct in another, as AddonReconciler embeds client.Client and so gains its Get, List, Update methods).
Interfaces: the most important idea¶
An interface is a set of method signatures. Any type that has those methods satisfies it, without declaring so. That lets code depend on what something does rather than what it is:
// Provider creates and removes the record pointing a host at the cluster's ingress.
type Provider interface {
// Ensure makes host resolve to the configured target. It reports
// ErrConflict when a record exists that rendimiento did not create.
Ensure(ctx context.Context, host, app string) error
// Remove deletes the record for host if, and only if, rendimiento created it for app.
Remove(ctx context.Context, host, app string) error
// Zones lists the domains available for new apps (wizard dropdown).
Zones(ctx context.Context) ([]string, error)
// Describe reports which provider this is and whether it is usable, for
// the environment page. Providers are swappable; callers never switch on type.
Describe(ctx context.Context) Description
}
The controller holds a dns.Provider. In production it is *dns.Cloudflare; without a token it is dns.Noop{}; in tests it is a fake that records calls in a map. The controller's code is the same in all three cases. This one idea is behind most of rendimiento's testability:
| Interface | Real implementation | Test implementation |
|---|---|---|
dns.Provider |
Cloudflare |
fakeDNS |
pipeline.Executor |
KubeExecutor (pods) |
fakeExec (sleeps, records order) |
platform.GitHub |
github.Holder (GitHub API) |
fakeGitHub (in memory) |
addon.GitFetcher |
GitHubFetcher |
fakeGit (fstest.MapFS) |
generate.Generator |
Templates |
— (future: an AI generator) |
io.Writer, fs.FS |
log recorders, a GitHub tree | buffers, in-memory file trees |
Go convention: accept interfaces, return concrete types, and keep interfaces small and defined where they are used (the platform declares the GitHub interface with exactly the methods it calls).
Errors are values¶
Functions return an error as their last result; callers check it right away. There are no exceptions:
raw, err := p.GitHub.FileAt(ctx, app.InstallationID, app.Repo, spec.FileName, ref)
if err != nil {
return nil, fmt.Errorf("read %s: %w", spec.FileName, err) // add context, keep the cause
}
%wwraps the cause, so callers can still ask about it witherrors.Is(a specific value) orerrors.As(a specific type).- Sentinel errors name a condition:
store.ErrNotFound, or the controller'serrBlocked("needs a human; retry slowly"):
var errBlocked = errors.New("blocked")
...
if err != nil && !errors.Is(err, errBlocked) {
return ctrl.Result{}, err // retried with backoff
}
errors.Joincombines many errors into one:spec.Validatereports every problem in a file at once.
context.Context: cancellation and deadlines¶
Almost every function that does I/O takes a ctx context.Context first. It carries a deadline and a cancellation signal down the call chain: when a run is cancelled or a step times out, its context is cancelled and every HTTP call, Kubernetes request and wait loop beneath it stops.
ctx, cancel := context.WithTimeout(ctx, k.Timeout) // a step may run at most STEP_TIMEOUT
defer cancel() // always release the timer
defer runs a call when the surrounding function returns, however it returns: the idiom for cleanup (closing files, deleting a build pod, releasing a lock).
Concurrency: goroutines, channels, mutexes¶
A goroutine is a lightweight thread: go f() runs f concurrently. Thousands are cheap. The CI runner starts one per step.
A channel passes values between goroutines, and can be used as a signal or a semaphore:
sem := make(chan struct{}, maxParallel) // a buffered channel with maxParallel slots
sem <- struct{}{} // take a slot (blocks when all are taken)
defer func() { <-sem }() // give it back
The runner uses exactly this to cap concurrent steps, and a closed channel per step (close(done[s.ID])) to tell dependents a step finished. select waits on several channels at once (a result, a timer, or cancellation).
A sync.Mutex protects shared data (the events hub's subscriber map, the renovate runner's "running" flag). sync.WaitGroup waits for a group of goroutines to finish. sync/atomic holds the GitHub App in a Holder that can be swapped at runtime after setup.
Rules of thumb used in the code: never share a map between goroutines without a lock; never block while holding a lock; always give goroutines a way to stop (a context).
Embedding files: //go:embed¶
Files can be compiled into the binary:
// Package web embeds the built UI (npm run build → web/dist).
package web
import (
"embed"
"io/fs"
)
//go:embed all:dist
var dist embed.FS
// Dist returns the built UI, or nil when the binary was built without it.
func Dist() fs.FS {
sub, err := fs.Sub(dist, "dist")
if err != nil {
return nil
}
if _, err := fs.Stat(sub, "index.html"); err != nil {
return nil
}
return sub
}
The UI (web/dist), the Dockerfile templates (templates/) and the SQL migrations (internal/store/migrations) are embedded this way, which is why the platform is one file.
Generics¶
Since Go 1.18, functions can take type parameters. rendimiento uses them sparingly, for tiny helpers:
Testing¶
Tests live next to the code in *_test.go files and run with go test ./.... A test is a function func TestX(t *testing.T); t.Errorf records a failure and continues, t.Fatalf stops the test. The common shape is the table-driven test:
for _, tc := range []struct{ yaml, want string }{
{"services: [{name: a, needs: [mysql]}]", "not something rendimiento provides"},
{"services: [{name: postgres}, {name: b, needs: [postgres]}]", "rename the service"},
} {
if _, err := Parse([]byte(tc.yaml)); err == nil || !strings.Contains(err.Error(), tc.want) {
t.Errorf("%s: got %v, want %q", tc.yaml, err, tc.want)
}
}
Standard helpers used throughout: httptest.NewServer (a fake HTTP server, e.g. a Helm repository), fstest.MapFS (an in-memory file tree), t.TempDir(). More in Testing.
Tooling¶
| Command | Does |
|---|---|
gofmt -w . |
Formats code the one standard way (run before every commit). |
go vet ./... |
Finds suspicious code (wrong format verbs, unreachable code…). |
go build ./... |
Compiles everything. |
go test ./pkg/... |
Runs tests. |
go mod tidy |
Syncs go.mod/go.sum with the imports. |
go doc pkg.Name |
Shows documentation from comments. |
Doc comments start with the name they describe (// Render returns every object…). Every exported name in this repository has one; the code map is generated from them.
Building a static binary¶
CGO_ENABLED=0 avoids linking any C code, so the binary needs no system libraries. GOOS/GOARCH cross-compile: GOOS=linux GOARCH=amd64 go build … on an arm64 Pi produces an x86 binary, no emulation needed.
Where to learn more¶
- A Tour of Go (an afternoon) and Effective Go.
- Go by Example for quick recipes.
- The controller-runtime book (Kubebuilder) for controllers and CRDs.