8.8 KiB
Development Guide — go-devops
Prerequisites
| Tool | Minimum version | Purpose |
|---|---|---|
| Go | 1.25 | Build and test |
| Task | any | Taskfile automation (optional, used by some builders) |
govulncheck |
latest | Vulnerability scanning (devkit.VulnCheck) |
gitleaks |
any | Secret scanning (devkit.ScanSecrets) |
gocyclo |
any | External complexity tool (devkit.Complexity) |
| SSH access | — | Integration tests for ansible/ package |
Install optional tools:
go install golang.org/x/vuln/cmd/govulncheck@latest
go install github.com/zricethezav/gitleaks/v8@latest
go install github.com/fzipp/gocyclo/cmd/gocyclo@latest
Local Dependency
go-devops depends on forge.lthn.ai/core/go (the parent framework). The go.mod replace directive resolves this locally:
replace forge.lthn.ai/core/go => ../core
The ../core path must exist relative to the go-devops checkout. If working in a Go workspace (go.work), add both modules:
go work init
go work use . ../core
Do not alter the replace directive path.
Build and Test
# Run all tests
go test ./...
# Run all tests with race detector
go test -race ./...
# Run a single test by name
go test -v -run TestName ./...
# Run tests in one package
go test ./ansible/...
# Static analysis
go vet ./...
# Check for vulnerabilities
govulncheck ./...
# View test coverage
go test -cover ./...
# Generate a coverage profile
go test -coverprofile=cover.out ./...
go tool cover -html=cover.out
Test Patterns
Naming Convention
Tests use _Good, _Bad, and _Ugly suffixes:
| Suffix | Meaning |
|---|---|
_Good |
Happy-path test; expected success |
_Bad |
Expected error condition; error must be returned |
_Ugly |
Panic, edge case, or degenerate input |
Example:
func TestParsePlaybook_Good(t *testing.T) { ... }
func TestParsePlaybook_Bad(t *testing.T) { ... }
func TestParsePlaybook_Ugly(t *testing.T) { ... }
Assertion Library
Use github.com/stretchr/testify. Prefer require over assert when subsequent assertions depend on the previous one passing:
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSomething_Good(t *testing.T) {
result, err := SomeFunction()
require.NoError(t, err)
assert.Equal(t, "expected", result.Field)
}
HTTP Test Servers
Use net/http/httptest for API client tests. The infra/ tests demonstrate the pattern:
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"id": 1}`))
}))
defer srv.Close()
client := NewHCloudClient("token", WithHTTPClient(srv.Client()))
SSH Mocking
The ansible/ package uses an sshRunner interface to decouple module implementations from real SSH connections. mock_ssh_test.go provides MockSSHClient with:
expectCommand(pattern, stdout, stderr, rc)— registers expected command patterns.hasExecuted(pattern)— asserts a command matching the pattern was called.hasExecutedMethod(method)— asserts a specific method (Run,RunScript,Upload) was called.- In-memory filesystem simulation for file operation tests.
Use MockSSHClient for all ansible/modules.go tests. Real SSH connections are not used in unit tests.
In-Memory Complexity Analysis
For devkit complexity tests, use AnalyseComplexitySource rather than writing temporary files:
src := `package foo
func Complex(x int) int {
if x > 0 { return x }
return -x
}`
results, err := AnalyseComplexitySource(src, "foo.go", 1)
require.NoError(t, err)
Coverage Store Tests
Use t.TempDir() to create temporary directories for CoverageStore persistence tests:
dir := t.TempDir()
store := NewCoverageStore(filepath.Join(dir, "coverage.json"))
Publisher Dry-Run Tests
All release/publishers/ tests use dryRun: true. No external services are called. Tests verify:
- Correct command-line argument construction.
- Correct file generation (formula text, manifest JSON, PKGBUILD content).
- Interface compliance: the publisher's
Name()is non-empty andPublishwith a nil config does not panic.
Coding Standards
Language
Use UK English in all documentation, comments, identifiers, log messages, and error strings:
- colour (not color)
- organisation (not organization)
- centre (not center)
- behaviour (not behavior)
- licence (noun, not license)
Strict Types
Every Go file must use strict typing. Avoid any at API boundaries where a concrete type is knowable. map[string]any is acceptable for Ansible task arguments and YAML-decoded data where the schema is dynamic.
Error Handling
Use the core.E helper from forge.lthn.ai/core/go for contextual errors:
return core.E("ansible.Executor.runTask", "failed to upload file", err)
For packages that do not import core/go, use fmt.Errorf with %w:
return fmt.Errorf("infra.HCloudClient.ListServers: %w", err)
Error strings must not be capitalised and must not end with punctuation (Go convention).
Import Order
Three groups, each separated by a blank line:
- Standard library
forge.lthn.ai/core/...packages- Third-party packages
import (
"context"
"fmt"
"forge.lthn.ai/core/go/pkg/io"
"gopkg.in/yaml.v3"
"golang.org/x/crypto/ssh"
)
File Headers
Source files do not require a licence header comment beyond the package declaration. The devkit/ package uses a trailing // LEK-1 | lthn.ai | EUPL-1.2 comment; maintain this convention in devkit/ files only.
Interface Placement
Define interfaces in the package that consumes them, not the package that implements them. The Builder, Publisher, Signer, Generator, Hypervisor, and ImageSource interfaces each live in the package that calls them.
Conventional Commits
All commits follow the Conventional Commits specification.
Format: type(scope): description
Scopes map to package names:
| Scope | Package |
|---|---|
ansible |
ansible/ |
build |
build/, build/builders/, build/signing/ |
container |
container/ |
devkit |
devkit/ |
devops |
devops/ |
infra |
infra/ |
release |
release/, release/publishers/ |
sdk |
sdk/, sdk/generators/ |
deploy |
deploy/ |
Examples:
feat(ansible): add docker_compose module support
fix(infra): handle nil Retry-After header in rate limiter
refactor(build): extract archive creation into separate function
test(devkit): expand coverage trending snapshot comparison tests
chore: update go.sum after dependency upgrade
Co-author line: every commit must include:
Co-Authored-By: Virgil <virgil@lethean.io>
Licence
All source files are licensed under the European Union Public Licence 1.2 (EUPL-1.2). Do not introduce dependencies with licences incompatible with EUPL-1.2. The github.com/kluctl/go-embed-python dependency (Apache 2.0) and golang.org/x/crypto (BSD-3-Clause) are compatible. Verify new dependencies before adding them.
Forge Repository
- Remote:
ssh://git@forge.lthn.ai:2223/core/go-devops.git - Push:
git push forge main - HTTPS authentication is not supported on the Forge instance; SSH is required.
Adding a New Module to ansible/
- Add the module name(s) to
KnownModulesintypes.go. - Implement a function
executeModuleName(ctx, ssh, args, vars) TaskResultinmodules.go. - Add a
case "modulename":branch in the dispatch switch inexecutor.go. - Add a shim to
mock_ssh_test.go'ssshRunnerinterface (if the module requires file operations, usesshFileRunner). - Write tests in
modules_*_test.gousing the mock infrastructure. Cover at minimum: success case, changed vs. unchanged, argument validation failure, and SSH error propagation.
Adding a New Release Publisher
- Create
release/publishers/myplatform.go. - Implement
Publisher:Name() string— return the platform name.Publish(ctx, release, pubCfg, relCfg, dryRun) error— whendryRunis true, log intent and return nil.
- Register the publisher in
release/config.goalongside existing publishers. - Write
release/publishers/myplatform_test.gowith dry-run tests. Follow the pattern of existing publisher tests: verify command arguments, generated file content, and interface compliance.
Adding a New Builder
- Create
build/builders/mylang.go. - Implement
Builder:Name() stringDetect(fs io.Medium, dir string) (bool, error)— check for a marker file.Build(ctx, cfg, targets) ([]Artifact, error)
- Register the builder in
build/buildcmd/. - Write tests verifying
Detect(marker present/absent) andBuild(at minimum with a mockio.Medium).