go-devops/docs/development.md

305 lines
8.8 KiB
Markdown
Raw Normal View History

# 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:
```bash
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
```bash
# 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:
```go
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:
```go
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:
```go
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:
```go
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:
```go
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 and `Publish` with 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:
```go
return core.E("ansible.Executor.runTask", "failed to upload file", err)
```
For packages that do not import `core/go`, use `fmt.Errorf` with `%w`:
```go
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:
1. Standard library
2. `forge.lthn.ai/core/...` packages
3. Third-party packages
```go
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/
1. Add the module name(s) to `KnownModules` in `types.go`.
2. Implement a function `executeModuleName(ctx, ssh, args, vars) TaskResult` in `modules.go`.
3. Add a `case "modulename":` branch in the dispatch switch in `executor.go`.
4. Add a shim to `mock_ssh_test.go`'s `sshRunner` interface (if the module requires file operations, use `sshFileRunner`).
5. Write tests in `modules_*_test.go` using the mock infrastructure. Cover at minimum: success case, changed vs. unchanged, argument validation failure, and SSH error propagation.
## Adding a New Release Publisher
1. Create `release/publishers/myplatform.go`.
2. Implement `Publisher`:
- `Name() string` — return the platform name.
- `Publish(ctx, release, pubCfg, relCfg, dryRun) error` — when `dryRun` is true, log intent and return nil.
3. Register the publisher in `release/config.go` alongside existing publishers.
4. Write `release/publishers/myplatform_test.go` with dry-run tests. Follow the pattern of existing publisher tests: verify command arguments, generated file content, and interface compliance.
## Adding a New Builder
1. Create `build/builders/mylang.go`.
2. Implement `Builder`:
- `Name() string`
- `Detect(fs io.Medium, dir string) (bool, error)` — check for a marker file.
- `Build(ctx, cfg, targets) ([]Artifact, error)`
3. Register the builder in `build/buildcmd/`.
4. Write tests verifying `Detect` (marker present/absent) and `Build` (at minimum with a mock `io.Medium`).