Compare commits

..

3 Commits

Author SHA1 Message Date
Stefan Goppelt f5f980cfde chore: remove package step from CI pipeline
continuous-integration/drone/push Build is passing Details
2026-03-29 20:48:40 +02:00
Stefan Goppelt 9b0d844ec3 docs: add releasing process documentation
chore: update go.mod dependencies and versions

chore: update go.sum with new dependency versions

scripts: add coverage check script

scripts: add release notes generation script

test: refactor tests to use goconvey for assertions

test: enhance webserver tests with additional cases and goconvey

fix: improve error logging in web server start and stop methods
2026-03-29 20:47:30 +02:00
Stefan Goppelt d5660ba8ca chore: apply project standards templates 2026-03-29 19:29:18 +02:00
19 changed files with 1303 additions and 207 deletions

View File

@ -2,35 +2,52 @@ kind: pipeline
type: docker
name: go-lib/micro
steps:
- name: prepare
image: golang:1.20.2
commands:
- mkdir -p .build
- go get -v ./...
- name: test
image: golang:1.20.2
commands:
- go test -v -coverprofile=.build/coverage.txt ./...
depends_on:
- prepare
- name: vulncheck
image: golang:1.20.2
commands:
- go install golang.org/x/vuln/cmd/govulncheck@latest
- govulncheck -json ./... > .build/vulncheck.json
depends_on:
- prepare
trigger:
event:
- push
- cron
- custom
- tag
ref:
include:
- refs/heads/**
- refs/tags/v*
volumes:
- name: dockersock
host:
path: /var/run/docker.sock
steps:
- name: test
image: golang:1.25.8
commands:
- go get ./...
- go vet ./...
- mkdir -p .build
- go test -v -coverprofile .build/coverage.out -coverpkg=./config,./web ./...
- go tool cover -func .build/coverage.out | tee .build/coverage.txt
- bash scripts/check-coverage.sh .build/coverage.out 80
- go install golang.org/x/vuln/cmd/govulncheck@latest
- govulncheck -json ./... > .build/vulncheck.json
- name: release-notes
image: golang:1.25.8
commands:
- git fetch --tags --force || true
- bash scripts/generate-release-notes.sh
when:
event:
- tag
status:
- success
- name: release
image: plugins/gitea-release
settings:
base_url: https://scm.yoorie.de
api_key:
from_secret: gitea_token
files:
- .build/coverage.txt
- .build/release-notes.md
title: ${DRONE_TAG}
note_from_file: .build/release-notes.md
when:
event:
- tag
status:
- success

16
.editorconfig Normal file
View File

@ -0,0 +1,16 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 4
[*.go]
indent_style = tab
indent_size = 4
[*.md]
trim_trailing_whitespace = false

17
.gitattributes vendored Normal file
View File

@ -0,0 +1,17 @@
* text=auto
# Ensure LF for shell scripts and common source/docs files
*.sh text eol=lf
*.bash text eol=lf
*.zsh text eol=lf
*.go text eol=lf
*.mod text eol=lf
*.sum text eol=lf
*.md text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
Makefile text eol=lf
# Keep native Windows script formats
*.bat text eol=crlf
*.cmd text eol=crlf

21
.githooks/README.md Normal file
View File

@ -0,0 +1,21 @@
# Git Hooks
This repository standard uses a project-local hooks directory:
- `.githooks/pre-commit`
Activate it once per repository:
```sh
git config core.hooksPath .githooks
```
The pre-commit hook validates for staged `.sh` files:
- executable bit in Git index (`100755`)
- LF line endings (no CRLF)
The pre-commit hook also validates staged `.md` files with `markdownlint`:
- no `markdownlint` errors or problems
- requires `markdownlint` CLI in PATH (for example via `npm install --global markdownlint-cli`)

40
.githooks/pre-commit Normal file
View File

@ -0,0 +1,40 @@
#!/usr/bin/env sh
set -eu
failed=0
cr=$(printf '\r')
staged_shell_files=$(git diff --cached --name-only --diff-filter=ACMR | grep -E '\.sh$' || true)
staged_markdown_files=$(git diff --cached --name-only --diff-filter=ACMR | grep -E '\.md$' || true)
for file in $staged_shell_files; do
mode=$(git ls-files --stage -- "$file" | awk '{print $1}')
if [ "$mode" != "100755" ]; then
echo "ERROR: $file is not executable in Git index. Run: git add --chmod=+x $file" >&2
failed=1
fi
if git show ":$file" | grep -q "$cr"; then
echo "ERROR: $file contains CRLF in staged content. Use LF line endings." >&2
failed=1
fi
done
if [ -n "$staged_markdown_files" ]; then
if ! command -v markdownlint >/dev/null 2>&1; then
echo "ERROR: markdownlint is required to validate staged Markdown files (.md)." >&2
echo "Install with npm: npm install --global markdownlint-cli" >&2
failed=1
else
# Validate the staged markdown files currently present in the working tree.
# This keeps the hook simple and fast for standard project usage.
if ! markdownlint $staged_markdown_files; then
failed=1
fi
fi
fi
if [ "$failed" -ne 0 ]; then
echo "Pre-commit check failed." >&2
exit 1
fi

11
.gitignore vendored
View File

@ -15,3 +15,14 @@
# Dependency directories (remove the comment below to include it)
# vendor/
.build/
bin/
dist/
tmp/
coverage/
coverage.out
*.coverprofile
.DS_Store
Thumbs.db
.vscode/
.idea/

88
AGENTS.md Normal file
View File

@ -0,0 +1,88 @@
# AGENTS.md
## Purpose
This file defines the default working agreement for AI coding agents and contributors in this repository.
## Documentation Language
Project language is English. All documentation, issues, pull requests, commit messages, and code comments should be written in English.
All newly created documentation in this project must be written in English.
All project documentation files must be stored under `docs/`, except `README.md` and `AGENTS.md`.
## Working Principles
1. Keep changes minimal and focused on the requested task.
2. Preserve existing public APIs unless a breaking change is explicitly requested.
3. Prefer clear, maintainable code over clever shortcuts.
4. Do not modify unrelated files.
5. Never add secrets, credentials, or tokens to the repository.
## Testing Expectations
1. Add or update tests for behavior changes.
2. Keep tests deterministic and fast.
3. Prefer table-driven tests where they improve readability.
4. Run relevant tests locally before finishing changes.
5. For Go projects, use `github.com/smartystreets/goconvey` as the standard test library.
## Build Artifacts and Reports
1. Builder logs and generated reports must be created under `.build/`.
2. The `.build/` directory must be excluded from version control via `.gitignore`.
## Git and Script Standards
1. Shell scripts (`*.sh`) must use LF line endings.
2. Shell scripts committed to the repository must be executable in Git index (mode `100755`).
3. When adding a new shell script, set execute permissions before commit: `git add --chmod=+x path/to/script.sh`.
## Git Bash Execution Defaults
1. Repository maintenance scripts are executed with Git Bash shell on Windows.
2. Default repository root is `~/git`.
3. The repository root can be overridden via `GO_GIT_ROOT`.
4. The standards repository defaults to `$ROOT_PATH/project-standards`.
5. The standards repository path can be overridden via `GO_PROJECT_STANDARDS`.
## Definition of Done (DoD)
### Purpose
The Definition of Done defines the minimum quality bar for every completed change in this repository.
### Mandatory Criteria
1. Tests
- Every code change is covered by tests where applicable.
- New functionality includes new tests.
- Bug fixes include at least one regression test.
- For Go projects, tests use `github.com/smartystreets/goconvey`.
- Automated test coverage is at least 80%.
1. Functional documentation
- Implemented functionality is documented.
- Public API-relevant changes are reflected in README and/or docs.
1. Documentation standards
- Documentation is written in English.
- Documentation files are placed under `docs/`.
- Exceptions: `README.md` and `AGENTS.md` remain at repository root.
### Technical Completion Criteria
1. Build and test status
- The project builds successfully.
- Relevant test commands run successfully.
1. No unresolved critical issues
- No new blocking errors are introduced.
- Known non-blocking warnings are acceptable only if unrelated to the change or documented.
1. SonarQube status
- No SonarQube errors are present.
1. Documentation structure
- Links to moved or newly added docs are valid.
- Documentation structure remains consistent with project rules.
### Review Checklist (Quick)
- [ ] Change is implemented and meets acceptance criteria.
- [ ] Tests were added/updated and pass.
- [ ] Go tests use `github.com/smartystreets/goconvey`.
- [ ] Automated test coverage is at least 80%.
- [ ] Functionality is documented.
- [ ] Documentation is in English.
- [ ] Documentation is located under `docs/` (except `README.md` and `AGENTS.md`).
- [ ] No SonarQube errors are present.
- [ ] No critical regressions found.

190
README.md
View File

@ -1,12 +1,188 @@
<div style="text-align:left"><img src="https://www.yoorie.de/img/favicon_32.png"/></div>
# go-lib/micro
# Go micro service library
[![Build Status](https://drone.yoorie.de/api/badges/go-lib/micro/status.svg)](https://drone.yoorie.de/go-lib/micro)
[![Release](https://raster.shields.io/badge/dynamic/json.png?label=release&url=https://scm.yoorie.de/api/v1/repos/go-lib/micro/releases&query=$[0].tag_name)](https://scm.yoorie.de/go-lib/micro/releases)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://scm.yoorie.de/go-lib/micro/src/branch/main/LICENSE)
[![Build Status](https://drone.yoorie.de/api/badges/go-lib/micro/status.svg)](https://drone.yoorie.de/go-lib/micro) [![Release](https://raster.shields.io/badge/dynamic/json.png?label=release&url=https://scm.yoorie.de/api/v1/repos/go-lib/micro/releases&query=$[0].tag_name)](https://scm.yoorie.de/go-lib/micro/releases) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://scm.yoorie.de/go-lib/micro/src/branch/main/LICENSE)
Lightweight Go helper library for building microservices with:
## Documentation
- Opinionated HTTP/HTTPS server bootstrap around chi.
- Built-in health and readiness endpoints.
- Convention-based YAML config loading with defaults.
Is missed so far and will be created soon.
## Installation
---
Copyright &copy; 2023 yoorie.de
```bash
go get scm.yoorie.de/go-lib/micro
```
## Packages
- [config/configfiles.go](config/configfiles.go): configuration discovery and loading.
- [web/server.go](web/server.go): HTTP/HTTPS server bootstrap and lifecycle.
- [web/health.go](web/health.go): health-check worker and endpoint payloads.
## Configuration Loading
The config package supports:
- Default values via struct tags.
- Loading from an explicit file path.
- Optional convention lookup based on an AppName method.
Lookup order for convention-based loading:
1. Executable directory with executable-name.yml
2. Executable directory with executable-name.yaml
3. User-local file in home directory under .myservice/config
4. Global config path returned by util.GetGlobalConfigurationFile(appName, "config")
Example:
```go
package main
import (
"log"
"scm.yoorie.de/go-lib/micro/config"
)
type ServiceConfig struct {
Port int `default:"7080" yaml:"port"`
Host string `default:"0.0.0.0" yaml:"host"`
}
// Optional: enables convention-based config file discovery.
func (c *ServiceConfig) AppName() string {
return "myservice"
}
func main() {
cfg := &ServiceConfig{}
// Option A: explicit file
if err := config.LoadConfigurationFromFile(cfg, "config.yml"); err != nil {
log.Fatal(err)
}
// Option B: automatic discovery using AppName()
// if err := config.LoadConfiguration(cfg); err != nil {
// log.Fatal(err)
// }
}
```
## Web Server
The web package wraps chi and starts:
- HTTP server when SslPort is not set.
- HTTPS server and separate HTTP health endpoint server when SslPort is set.
Default health endpoints:
- /health/healthz
- /health/readyz
Example:
```go
package main
import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
"scm.yoorie.de/go-lib/micro/web"
)
type Message struct {
Text string `json:"text"`
}
func routes() *chi.Mux {
r := chi.NewRouter()
r.Get("/hello", func(w http.ResponseWriter, r *http.Request) {
render.JSON(w, r, Message{Text: "hello"})
})
return r
}
func main() {
cfg := &web.WebServerConfiguration{
Host: "0.0.0.0",
Port: 7080,
}
server, err := web.NewWebServer(cfg)
if err != nil {
panic(err)
}
// Optional custom health function.
server.HealthCheck = func() (bool, string) {
return true, ""
}
server.Mount("/api", routes())
if err := server.Start(); err != nil {
panic(err)
}
// Block until interrupt.
server.Join()
}
```
## Development
Run unit tests:
```bash
go test ./...
```
Run vet:
```bash
go vet ./...
```
Run coverage like CI:
```bash
mkdir -p .build
go test -v -coverprofile .build/coverage.out -coverpkg=./config,./web ./...
go tool cover -func .build/coverage.out
bash scripts/check-coverage.sh .build/coverage.out 80
```
Run vulnerability scan:
```bash
go install golang.org/x/vuln/cmd/govulncheck@latest
govulncheck ./...
```
## CI and Release
Drone pipeline is defined in [.drone.yml](.drone.yml) and includes:
- Tests
- Coverage gate (80%)
- go vet
- govulncheck
- Tag-based release notes and packaging
For the complete release workflow see [docs/RELEASING.md](docs/RELEASING.md).
## Project Docs
- [docs/DEFINITION_OF_DONE.md](docs/DEFINITION_OF_DONE.md)
- [docs/RELEASING.md](docs/RELEASING.md)
## License
MIT, see [LICENSE](LICENSE).

View File

@ -1,9 +1,7 @@
package config
import (
"errors"
"fmt"
"io/ioutil"
"os"
"os/user"
"path/filepath"
@ -16,7 +14,10 @@ import (
func GetConfigurationFiles(appName string) []string {
// First checking local config
executablePath, _ := os.Executable()
executablePath, executableErr := os.Executable()
if executableErr != nil {
executablePath = os.Args[0]
}
fname := filepath.Base(executablePath)
fext := filepath.Ext(executablePath)
fdir := filepath.Dir(executablePath)
@ -26,8 +27,11 @@ func GetConfigurationFiles(appName string) []string {
for _, ext := range ymlExts {
fileNames = append(fileNames, filepath.Join(fdir, fnameWoExt+ext))
}
usr, _ := user.Current()
fileNames = append(fileNames, filepath.Join(usr.HomeDir, "."+appName, "config"))
homeDir := "."
if usr, userErr := user.Current(); userErr == nil {
homeDir = usr.HomeDir
}
fileNames = append(fileNames, filepath.Join(homeDir, "."+appName, "config"))
// Check for global config
fileNames = append(fileNames, util.GetGlobalConfigurationFile(appName, "config"))
return fileNames
@ -48,6 +52,33 @@ func LoadConfiguration[T any](config T) error {
return LoadConfigurationFromFile(config, "")
}
func resolveConfigFile[T any](config T, configFile string) (string, error) {
if configFile != "" {
if !util.FileExists(configFile) {
return "", fmt.Errorf("given configuration file %s cannot be found", configFile)
}
return configFile, nil
}
appName := getAppNameFromConfig(config)
if appName == "" {
return "", nil
}
return GetConfigurationFile(appName), nil
}
func getAppNameFromConfig[T any](config T) string {
method := reflect.ValueOf(config).MethodByName("AppName")
if !method.IsValid() {
return ""
}
results := method.Call(nil)
if len(results) == 0 {
return ""
}
return results[0].String()
}
func IsStructPointer[T any](config T) bool {
value := reflect.ValueOf(config)
return value.Type().Kind() == reflect.Pointer && reflect.Indirect(value).Type().Kind() == reflect.Struct
@ -56,32 +87,25 @@ func IsStructPointer[T any](config T) bool {
func LoadConfigurationFromFile[T any](config T, configFile string) error {
if !IsStructPointer(config) {
genericType := reflect.TypeOf(config)
return errors.New(fmt.Sprintf("Type: %s.%s is not a struct pointer", genericType.PkgPath(), genericType.Name()))
return fmt.Errorf("type: %s.%s is not a struct pointer", genericType.PkgPath(), genericType.Name())
}
defaults.Set(config)
if configFile != "" {
if !util.FileExists(configFile) {
return errors.New(fmt.Sprintf("given configuration file %s cannot be found", configFile))
}
} else {
if method := reflect.ValueOf(config).MethodByName("appName"); !method.IsNil() {
result := method.String()
if result != "" {
configFile = GetConfigurationFile(result)
}
}
resolvedConfigFile, err := resolveConfigFile(config, configFile)
if err != nil {
return err
}
if configFile == "" {
if resolvedConfigFile == "" {
return nil
}
ymldata, err := ioutil.ReadFile(configFile)
ymldata, err := os.ReadFile(resolvedConfigFile)
if err != nil {
return errors.New(fmt.Sprintf("cannot read configuration file %s. %v", configFile, err))
return fmt.Errorf("cannot read configuration file %s: %w", resolvedConfigFile, err)
}
err = yaml.Unmarshal(ymldata, config)
if err != nil {
return errors.New(fmt.Sprintf("error unmarshalling configuration data: %v", err))
return fmt.Errorf("error unmarshalling configuration data: %w", err)
}
return nil
}

View File

@ -0,0 +1,52 @@
# Definition of Done (DoD)
## Purpose
This Definition of Done defines the minimum quality bar for every completed change in this repository.
## Mandatory Criteria
1. Tests
- Every code change is covered by tests where applicable.
- New functionality includes new tests.
- Bug fixes include at least one regression test.
- For Go projects, tests use `github.com/smartystreets/goconvey`.
- Automated test coverage is at least 80%.
1. Functional documentation
- Implemented functionality is documented.
- Public API-relevant changes are reflected in README and/or docs.
1. Documentation standards
- Documentation is written in English.
- Documentation files are placed under `docs/`.
- Exceptions: `README.md` and `AGENTS.md` remain at repository root.
## Technical Completion Criteria
1. Build and test status
- The project builds successfully.
- Relevant test commands run successfully.
1. No unresolved critical issues
- No new blocking errors are introduced.
- Known non-blocking warnings are acceptable only if unrelated to the change or documented.
1. SonarQube status
- No SonarQube errors are present.
1. Documentation links and structure
- Links to moved or newly added docs are valid.
- Documentation structure remains consistent with project rules.
## Review Checklist (Quick)
- [ ] Change is implemented and meets acceptance criteria.
- [ ] Tests were added/updated and pass.
- [ ] Go tests use `github.com/smartystreets/goconvey`.
- [ ] Automated test coverage is at least 80%.
- [ ] Functionality is documented.
- [ ] Documentation is in English.
- [ ] Documentation is located under `docs/` (except `README.md` and `AGENTS.md`).
- [ ] No SonarQube errors are present.
- [ ] No critical regressions found.

115
docs/RELEASING.md Normal file
View File

@ -0,0 +1,115 @@
# Releasing go-lib/micro
This document describes the process for creating a release of the
`go-lib/micro` library.
## Overview
Releases in this project are managed via Git tags. When you push a
tag matching the pattern `v*` (for example, `v0.2.0`), the Drone
pipeline automatically:
1. Runs quality checks (tests, coverage gate, vet, vulnerability scan)
2. Generates release notes from commits
3. Creates a source archive (`sources.tar.gz`)
4. Publishes a release to Gitea with attached artifacts
## Versioning
This library follows semantic versioning: `MAJOR.MINOR.PATCH`
(for example, `v1.2.3`).
- `v1.0.0`: breaking API changes
- `v1.1.0`: backward-compatible features
- `v1.0.1`: backward-compatible fixes
## Prerequisites
Before creating a release, ensure:
1. Working tree is clean (`git status`)
2. Tests pass locally (`go test ./...`)
3. Coverage is at least 80%
4. Dependencies are tidy (`go mod tidy`)
5. Documentation is up to date (README/docs)
## Create a Release
### 1. Prepare (optional)
Update docs, examples, or changelog if needed:
```bash
git add README.md docs/
git commit -m "docs: prepare release"
```
### 2. Create tag
```bash
git tag -a v0.2.0 -m "Release v0.2.0"
```
### 3. Push tag
```bash
git push origin v0.2.0
```
### 4. Verify pipeline and release
After pushing the tag, verify in Drone and Gitea:
- Pipeline succeeded for tag build
- Gitea release exists with artifacts:
- `.build/coverage.txt`
- `.build/sources.tar.gz`
- `.build/release-notes.md`
## Install Released Version
```bash
# latest
go get scm.yoorie.de/go-lib/micro
# specific
go get scm.yoorie.de/go-lib/micro@v0.2.0
```
## Troubleshooting
### Coverage below threshold
Run locally and inspect uncovered code:
```bash
go test -v -coverprofile .build/coverage.out ./...
go tool cover -func .build/coverage.out
```
### Release step fails
Common causes:
- Missing Drone secret `gitea_token`
- Tag does not match `v*`
- Earlier pipeline step failed
### Wrong tag pushed
```bash
git tag -d v0.2.0
git push origin :refs/tags/v0.2.0
```
Then create and push the corrected tag.
## Release Checklist
- [ ] Tests pass locally
- [ ] Coverage is at least 80%
- [ ] `go vet ./...` passes
- [ ] `govulncheck ./...` is clean or reviewed
- [ ] Tag `vX.Y.Z` created and pushed
- [ ] Drone pipeline succeeded
- [ ] Gitea release contains expected artifacts

13
go.mod
View File

@ -7,22 +7,23 @@ require (
github.com/go-chi/chi/v5 v5.0.10
github.com/go-chi/cors v1.2.1
github.com/go-chi/render v1.0.3
github.com/stretchr/testify v1.8.4
github.com/smartystreets/goconvey v1.8.1
gopkg.in/yaml.v3 v3.0.1
scm.yoorie.de/go-lib/certs v0.0.2
scm.yoorie.de/go-lib/gelf v0.0.2
scm.yoorie.de/go-lib/util v0.5.0
scm.yoorie.de/go-lib/certs v0.0.3
scm.yoorie.de/go-lib/gelf v0.0.3
scm.yoorie.de/go-lib/util v0.6.0
)
require (
github.com/ajg/form v1.5.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/gopherjs/gopherjs v1.17.2 // indirect
github.com/jtolds/gls v4.20.0+incompatible // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/sergi/go-diff v1.3.1 // indirect
github.com/smarty/assertions v1.15.0 // indirect
golang.org/x/crypto v0.11.0 // indirect
golang.org/x/sys v0.10.0 // indirect
gopkg.in/aphistic/golf.v0 v0.0.0-20180712155816-02c07f170c5a // indirect

42
go.sum
View File

@ -5,7 +5,6 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
github.com/creasty/defaults v1.7.0 h1:eNdqZvc5B509z18lD8yc212CAqJNvfT1Jq6L8WowdBA=
github.com/creasty/defaults v1.7.0/go.mod h1:iGzKe6pbEHnpMPtfDXZEr0NVxWnPTjb1bbDy08fPzYM=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk=
github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
@ -13,9 +12,14 @@ github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4=
github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@ -32,31 +36,33 @@ github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQ
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/onsi/gomega v1.27.2 h1:SKU0CXeKE/WVgIV1T61kSa3+IRE8Ekrv9rdXDwwTqnY=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY=
github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY=
github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA=
golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.10.0 h1:3R7pNqamzBraeqj/Tj8qt1aQ2HpmlC+Cx/qL/7hn4/c=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
gopkg.in/aphistic/golf.v0 v0.0.0-20180712155816-02c07f170c5a h1:34vqlRjuZiE9c8eHsuZ9nn+GbcimFpvGUEmW+vyfhG8=
gopkg.in/aphistic/golf.v0 v0.0.0-20180712155816-02c07f170c5a/go.mod h1:fvTxI2ZW4gO1d+4q4VCKOo+ANBs4gPN3IW00MlCumKc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@ -65,13 +71,11 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
scm.yoorie.de/go-lib/certs v0.0.2 h1:GD+cIMTAGTQ2R/mNtEoJzHTPRxIIfORdB/PmhfKYSKU=
scm.yoorie.de/go-lib/certs v0.0.2/go.mod h1:NMSvuFPENHTtsC9VAPHUAYgPSAKWl/Bh/Z7p+ymbdqg=
scm.yoorie.de/go-lib/gelf v0.0.2 h1:DEvenZpnCzNgXctaECwdJ0hqyH5j02eWQKzEdTriBzs=
scm.yoorie.de/go-lib/gelf v0.0.2/go.mod h1:55m5AWDVkAUJoFVZ0AVYCm5RwucBSV3yObl2jfKdGnw=
scm.yoorie.de/go-lib/util v0.5.0 h1:O5cF/yGPjuE2QYDcjQkPb9SNStEaW0U4RldasKU6Ttw=
scm.yoorie.de/go-lib/util v0.5.0/go.mod h1:FwOw8Q5Bl88u2kiL3BPU3qjdbixfl7g72TJpRh/5HAc=
scm.yoorie.de/go-lib/certs v0.0.3 h1:a7wCAJQj1X+NxwS8r2o49m29oBh1/YvqkUsMQR2GUyk=
scm.yoorie.de/go-lib/certs v0.0.3/go.mod h1:2z4kefIECDXFJrMnRRFO2tax4Udy2WeABkLN3kWgItg=
scm.yoorie.de/go-lib/gelf v0.0.3 h1:3tZQdDQNJisyVQRyY4YfUYwp3dF+s7NlI12noXiiJdg=
scm.yoorie.de/go-lib/gelf v0.0.3/go.mod h1:zZrZ4zGuKFYh8AIeA0nPHWXWHrLoNiKIQ/KrSQhky+s=
scm.yoorie.de/go-lib/util v0.6.0 h1:74Z4vALoMNgsSeETTIC77qTVYoE0QMafsJhCQqw7Y+g=
scm.yoorie.de/go-lib/util v0.6.0/go.mod h1:t9TLwQzr8F1g+SLn9llFbGS0a5hrP+M89IrrOCVpszU=

32
scripts/check-coverage.sh Executable file
View File

@ -0,0 +1,32 @@
#!/bin/bash
# Check test coverage against minimum threshold.
set -euo pipefail
COVERAGE_FILE="${1:-.build/coverage.out}"
MIN_COVERAGE="${2:-80}"
if [ ! -f "$COVERAGE_FILE" ]; then
echo "Error: coverage file not found: $COVERAGE_FILE"
exit 1
fi
# Extract total coverage percentage from go tool cover output.
COVERAGE=$(go tool cover -func "$COVERAGE_FILE" | awk '/^total:/ { match($0, /[0-9.]+%/); print substr($0, RSTART, RLENGTH-1) }')
if [ -z "$COVERAGE" ]; then
echo "Error: failed to parse coverage from $COVERAGE_FILE"
exit 1
fi
echo "Total coverage: ${COVERAGE}%"
# Compare as integer part to keep shell arithmetic simple and portable.
COVERAGE_INT=${COVERAGE%.*}
if [ "$COVERAGE_INT" -lt "$MIN_COVERAGE" ]; then
echo "Coverage ${COVERAGE}% is below minimum ${MIN_COVERAGE}%"
exit 1
fi
echo "Coverage check passed"

View File

@ -0,0 +1,76 @@
#!/bin/bash
# Generate release notes from commits since the previous tag.
set -euo pipefail
CURRENT_TAG="${1:-${DRONE_TAG:-}}"
OUTPUT_FILE="${2:-.build/release-notes.md}"
if [ -z "$CURRENT_TAG" ]; then
echo "Error: current tag not provided"
exit 1
fi
# In CI, the tag name may be available but not present as a local ref.
CURRENT_REF="$CURRENT_TAG"
if ! git rev-parse --verify -q "${CURRENT_REF}^{commit}" >/dev/null; then
CURRENT_REF="${DRONE_COMMIT:-HEAD}"
fi
PREVIOUS_TAG=$(git describe --tags --abbrev=0 "${CURRENT_REF}^" 2>/dev/null || echo "")
mkdir -p "$(dirname "$OUTPUT_FILE")"
{
echo "# Release $CURRENT_TAG"
echo
if [ -n "$PREVIOUS_TAG" ]; then
echo "Changes since $PREVIOUS_TAG:"
echo
git log "$PREVIOUS_TAG..$CURRENT_REF" --pretty=format:"%s" | while read -r commit; do
if [[ $commit =~ ^([a-z]+)(\(.+\))?:\ (.+)$ ]]; then
TYPE="${BASH_REMATCH[1]}"
SCOPE="${BASH_REMATCH[2]}"
MESSAGE="${BASH_REMATCH[3]}"
case "$TYPE" in
feat)
echo "- Feature${SCOPE}: $MESSAGE" ;;
fix)
echo "- Fix${SCOPE}: $MESSAGE" ;;
docs)
echo "- Docs${SCOPE}: $MESSAGE" ;;
ci)
echo "- CI${SCOPE}: $MESSAGE" ;;
test)
echo "- Test${SCOPE}: $MESSAGE" ;;
refactor)
echo "- Refactor${SCOPE}: $MESSAGE" ;;
perf)
echo "- Performance${SCOPE}: $MESSAGE" ;;
*)
echo "- $commit" ;;
esac
else
echo "- $commit"
fi
done
else
echo "Initial release"
echo
git log "$CURRENT_REF" --pretty=format:"- %s" | head -20
fi
echo
echo "---"
if [ -n "$PREVIOUS_TAG" ]; then
echo "See all changes: https://scm.yoorie.de/git/go-lib/micro/compare/$PREVIOUS_TAG...$CURRENT_TAG"
else
echo "See all changes: https://scm.yoorie.de/git/go-lib/micro/releases/tag/$CURRENT_TAG"
fi
} > "$OUTPUT_FILE"
echo "Release notes generated: $OUTPUT_FILE"
cat "$OUTPUT_FILE"

View File

@ -0,0 +1,274 @@
#!/usr/bin/env sh
set -eu
ROOT_PATH="${GO_GIT_ROOT:-$HOME/git}"
STANDARDS_REPO=""
STANDARDS_REPO_SET=0
EXCLUDE_NAME="project-standards"
CHECK_ONLY=0
WATCH=0
INTERVAL=60
usage() {
cat <<'EOF'
Usage:
reconcile-project-standards.sh [options]
Options:
--root <path> Root folder containing repositories
--standards-repo <path> Path to project-standards repository
--exclude <name> First-level directory name to skip (default: project-standards)
--check-only Check drift only, do not update files
--watch Continuously scan and reconcile
--interval <seconds> Watch interval in seconds (default: 60)
-h, --help Show this help
Environment:
GO_GIT_ROOT Overrides default root path (default: ~/git)
GO_PROJECT_STANDARDS Overrides standards repository path
Default resolution:
ROOT_PATH defaults to GO_GIT_ROOT or ~/git.
STANDARDS_REPO defaults to GO_PROJECT_STANDARDS or $ROOT_PATH/project-standards.
EOF
}
while [ "$#" -gt 0 ]; do
case "$1" in
--root)
ROOT_PATH=$2
shift 2
;;
--standards-repo)
STANDARDS_REPO=$2
STANDARDS_REPO_SET=1
shift 2
;;
--exclude)
EXCLUDE_NAME=$2
shift 2
;;
--check-only)
CHECK_ONLY=1
shift
;;
--watch)
WATCH=1
shift
;;
--interval)
INTERVAL=$2
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown option: $1" >&2
usage >&2
exit 1
;;
esac
done
if [ "$STANDARDS_REPO_SET" -ne 1 ]; then
if [ -n "${GO_PROJECT_STANDARDS:-}" ]; then
STANDARDS_REPO="$GO_PROJECT_STANDARDS"
else
STANDARDS_REPO="$ROOT_PATH/project-standards"
fi
fi
if [ "$INTERVAL" -lt 5 ]; then
echo "interval must be >= 5" >&2
exit 1
fi
AGENTS_TEMPLATE="$STANDARDS_REPO/templates/AGENTS.base.md"
DOD_TEMPLATE="$STANDARDS_REPO/templates/DEFINITION_OF_DONE.base.md"
GITIGNORE_TEMPLATE="$STANDARDS_REPO/templates/.gitignore.base"
GITATTRIBUTES_TEMPLATE="$STANDARDS_REPO/templates/.gitattributes.base"
EDITORCONFIG_TEMPLATE="$STANDARDS_REPO/templates/.editorconfig.base"
PRECOMMIT_TEMPLATE="$STANDARDS_REPO/templates/pre-commit.base.sh"
HOOKS_README_TEMPLATE="$STANDARDS_REPO/templates/.githooks.README.base.md"
if [ ! -f "$AGENTS_TEMPLATE" ]; then
echo "AGENTS template not found: $AGENTS_TEMPLATE" >&2
exit 1
fi
if [ ! -f "$DOD_TEMPLATE" ]; then
echo "DoD template not found: $DOD_TEMPLATE" >&2
exit 1
fi
if [ ! -f "$GITIGNORE_TEMPLATE" ]; then
echo "gitignore template not found: $GITIGNORE_TEMPLATE" >&2
exit 1
fi
if [ ! -f "$GITATTRIBUTES_TEMPLATE" ]; then
echo "gitattributes template not found: $GITATTRIBUTES_TEMPLATE" >&2
exit 1
fi
if [ ! -f "$EDITORCONFIG_TEMPLATE" ]; then
echo "editorconfig template not found: $EDITORCONFIG_TEMPLATE" >&2
exit 1
fi
if [ ! -f "$PRECOMMIT_TEMPLATE" ]; then
echo "pre-commit hook template not found: $PRECOMMIT_TEMPLATE" >&2
exit 1
fi
if [ ! -f "$HOOKS_README_TEMPLATE" ]; then
echo "hooks readme template not found: $HOOKS_README_TEMPLATE" >&2
exit 1
fi
hash_or_missing() {
path=$1
if [ ! -f "$path" ]; then
printf "%s" "__MISSING__"
return 0
fi
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$path" | awk '{print $1}'
else
shasum -a 256 "$path" | awk '{print $1}'
fi
}
ensure_file() {
template=$1
target=$2
template_hash=$(hash_or_missing "$template")
target_hash=$(hash_or_missing "$target")
if [ "$template_hash" = "$target_hash" ]; then
printf "%s" "ok"
return 0
fi
if [ "$CHECK_ONLY" -eq 1 ]; then
printf "%s" "drift"
return 0
fi
mkdir -p "$(dirname "$target")"
cp "$template" "$target"
printf "%s" "updated"
}
ensure_gitignore_entries_from_template() {
template_path=$1
gitignore_path=$2
missing=0
if [ ! -f "$gitignore_path" ]; then
if [ "$CHECK_ONLY" -eq 1 ]; then
printf "%s" "drift"
return 0
fi
: > "$gitignore_path"
fi
while IFS= read -r entry; do
entry=$(printf '%s' "$entry" | tr -d '\r')
[ -n "$entry" ] || continue
case "$entry" in
\#*)
continue
;;
esac
if tr -d '\r' < "$gitignore_path" | grep -Fqx "$entry"; then
continue
fi
missing=1
if [ "$CHECK_ONLY" -ne 1 ]; then
printf '%s\n' "$entry" >> "$gitignore_path"
fi
done < "$template_path"
if [ "$missing" -eq 0 ]; then
printf "%s" "ok"
return 0
fi
if [ "$CHECK_ONLY" -eq 1 ]; then
printf "%s" "drift"
else
printf "%s" "updated"
fi
}
run_once() {
scanned=0
updated=0
drift=0
for repo in "$ROOT_PATH"/*; do
[ -d "$repo" ] || continue
name=$(basename "$repo")
[ "$name" = "$EXCLUDE_NAME" ] && continue
scanned=$((scanned + 1))
agents_target="$repo/AGENTS.md"
dod_target="$repo/docs/DEFINITION_OF_DONE.md"
gitattributes_target="$repo/.gitattributes"
editorconfig_target="$repo/.editorconfig"
precommit_target="$repo/.githooks/pre-commit"
hooks_readme_target="$repo/.githooks/README.md"
gitignore_target="$repo/.gitignore"
agents_state=$(ensure_file "$AGENTS_TEMPLATE" "$agents_target")
dod_state=$(ensure_file "$DOD_TEMPLATE" "$dod_target")
gitattributes_state=$(ensure_file "$GITATTRIBUTES_TEMPLATE" "$gitattributes_target")
editorconfig_state=$(ensure_file "$EDITORCONFIG_TEMPLATE" "$editorconfig_target")
precommit_state=$(ensure_file "$PRECOMMIT_TEMPLATE" "$precommit_target")
hooks_readme_state=$(ensure_file "$HOOKS_README_TEMPLATE" "$hooks_readme_target")
gitignore_state=$(ensure_gitignore_entries_from_template "$GITIGNORE_TEMPLATE" "$gitignore_target")
if [ "$agents_state" = "updated" ] || [ "$dod_state" = "updated" ] || [ "$gitattributes_state" = "updated" ] || [ "$editorconfig_state" = "updated" ] || [ "$precommit_state" = "updated" ] || [ "$hooks_readme_state" = "updated" ] || [ "$gitignore_state" = "updated" ]; then
updated=$((updated + 1))
echo "UPDATED: $repo"
continue
fi
if [ "$agents_state" = "drift" ] || [ "$dod_state" = "drift" ] || [ "$gitattributes_state" = "drift" ] || [ "$editorconfig_state" = "drift" ] || [ "$precommit_state" = "drift" ] || [ "$hooks_readme_state" = "drift" ] || [ "$gitignore_state" = "drift" ]; then
drift=$((drift + 1))
echo "DRIFT: $repo"
continue
fi
echo "OK: $repo"
done
echo "Summary -> scanned=$scanned, updated=$updated, drift=$drift"
if [ "$CHECK_ONLY" -eq 1 ] && [ "$drift" -gt 0 ]; then
return 1
fi
return 0
}
if [ "$WATCH" -eq 1 ]; then
while :; do
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Reconciling project standards..."
run_once || true
sleep "$INTERVAL"
done
else
run_once
fi

View File

@ -3,7 +3,7 @@ package test
import (
"testing"
"github.com/stretchr/testify/assert"
. "github.com/smartystreets/goconvey/convey"
cfg "scm.yoorie.de/go-lib/micro/config"
)
@ -13,70 +13,88 @@ type MyConfig struct {
Value2 int `default:"8080" yaml:"value2"`
}
func (config *MyConfig) appName() string {
func (config *MyConfig) AppName() string {
return "myconfig"
}
func TestIsStructPointerStruct(t *testing.T) {
assert.False(t, cfg.IsStructPointer(MyConfig{}))
}
func TestIsStructPointerString(t *testing.T) {
assert.False(t, cfg.IsStructPointer("Bar"))
}
func TestIsStructPointerSimpleArray(t *testing.T) {
assert.False(t, cfg.IsStructPointer([2]int{292, 2}))
}
func TestIsStructPointerInt(t *testing.T) {
assert.False(t, cfg.IsStructPointer(12))
}
func TestIsStructPointerBool(t *testing.T) {
assert.False(t, cfg.IsStructPointer(false))
}
func TestIsStructPointerStructPointer(t *testing.T) {
assert.True(t, cfg.IsStructPointer(&MyConfig{}))
}
func TestIsStructPointerStructPointerArray(t *testing.T) {
cfg1 := &MyConfig{
Value: "Foo",
}
cfg2 := &MyConfig{
Value: "Bar",
}
func TestIsStructPointer(t *testing.T) {
Convey("IsStructPointer", t, func() {
Convey("returns false for struct value", func() {
So(cfg.IsStructPointer(MyConfig{}), ShouldBeFalse)
})
Convey("returns false for string", func() {
So(cfg.IsStructPointer("Bar"), ShouldBeFalse)
})
Convey("returns false for simple array", func() {
So(cfg.IsStructPointer([2]int{292, 2}), ShouldBeFalse)
})
Convey("returns false for int", func() {
So(cfg.IsStructPointer(12), ShouldBeFalse)
})
Convey("returns false for bool", func() {
So(cfg.IsStructPointer(false), ShouldBeFalse)
})
Convey("returns true for struct pointer", func() {
So(cfg.IsStructPointer(&MyConfig{}), ShouldBeTrue)
})
Convey("returns false for struct pointer array", func() {
cfg1 := &MyConfig{Value: "Foo"}
cfg2 := &MyConfig{Value: "Bar"}
array := [2]*MyConfig{cfg1, cfg2}
assert.False(t, cfg.IsStructPointer(array))
}
func TestLoadConfigurationFromFileWithNoPointerInvalidType(t *testing.T) {
myConfig := MyConfig{}
err := cfg.LoadConfigurationFromFile(myConfig, "testdata/config.yml")
assert.NotNil(t, err)
So(cfg.IsStructPointer(array), ShouldBeFalse)
})
})
}
func TestLoadConfigurationFromFile(t *testing.T) {
Convey("LoadConfigurationFromFile", t, func() {
Convey("returns error for non-pointer type", func() {
err := cfg.LoadConfigurationFromFile(MyConfig{}, "testdata/config.yml")
So(err, ShouldNotBeNil)
})
Convey("loads config from file successfully", func() {
myConfig := &MyConfig{}
assert.Equal(t, "myconfig", myConfig.appName())
cfg.LoadConfigurationFromFile(myConfig, "testdata/config.yml")
assert.Equal(t, "Foo", myConfig.Value)
assert.Equal(t, true, myConfig.Value1)
assert.Equal(t, 8080, myConfig.Value2)
}
func TestLoadConfigurationFromFileNotExist(t *testing.T) {
err := cfg.LoadConfigurationFromFile(myConfig, "testdata/config.yml")
So(err, ShouldBeNil)
So(myConfig.Value, ShouldEqual, "Foo")
So(myConfig.Value1, ShouldBeTrue)
So(myConfig.Value2, ShouldEqual, 8080)
})
Convey("returns error when config file does not exist", func() {
myConfig := &MyConfig{}
assert.Equal(t, "myconfig", myConfig.appName())
err := cfg.LoadConfigurationFromFile(myConfig, "testdata/config1.yml")
assert.NotNil(t, err)
So(err, ShouldNotBeNil)
})
})
}
func TestGetConfigurationFiles(t *testing.T) {
Convey("GetConfigurationFiles returns 4 candidate paths", t, func() {
fileNames := cfg.GetConfigurationFiles("myapp")
assert.Equal(t, 4, len(fileNames))
So(len(fileNames), ShouldEqual, 4)
})
}
for _, fileName := range fileNames {
t.Log(fileName)
func TestGetConfigurationFile(t *testing.T) {
Convey("GetConfigurationFile", t, func() {
Convey("returns empty string when no matching config file exists", func() {
result := cfg.GetConfigurationFile("nonexistentapp_xyz_123")
So(result, ShouldEqual, "")
})
})
}
func TestLoadConfiguration(t *testing.T) {
Convey("LoadConfiguration", t, func() {
Convey("returns error for non-pointer type", func() {
err := cfg.LoadConfiguration(MyConfig{})
So(err, ShouldNotBeNil)
})
Convey("returns nil for struct pointer with no config file found", func() {
myConfig := &MyConfig{}
err := cfg.LoadConfiguration(myConfig)
So(err, ShouldBeNil)
So(myConfig.Value, ShouldEqual, "Bar")
})
})
}

View File

@ -5,15 +5,15 @@ import (
"encoding/json"
"fmt"
"io"
"io/ioutil"
"net"
"net/http"
"os"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
"github.com/stretchr/testify/assert"
. "github.com/smartystreets/goconvey/convey"
"scm.yoorie.de/go-lib/micro/web"
"scm.yoorie.de/go-lib/util"
)
@ -24,6 +24,12 @@ var (
sslPort int
)
const (
readyMessage = "service is ready"
aMessage = "A message"
testFailureMsg = "test failure"
)
type MyData struct {
Message string `json:"message"`
Count int `json:"count"`
@ -32,7 +38,7 @@ type MyData struct {
func MyTestEndpoint(w http.ResponseWriter, r *http.Request) {
internalCount++
render.JSON(w, r, &MyData{
Message: "A message",
Message: aMessage,
Count: internalCount,
})
}
@ -91,7 +97,9 @@ func TestMain(m *testing.M) {
panic(err)
}
server.Mount("/api", CreateTestRouter())
server.Start()
if err = server.Start(); err != nil {
panic(err)
}
// Allow insecure calls to https
http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
@ -101,56 +109,167 @@ func TestMain(m *testing.M) {
os.Exit(exitVal)
}
func TestReady(t *testing.T) {
uri := getServerURL(false, "/readyz")
func TestNewWebServerNilConfig(t *testing.T) {
Convey("NewWebServer with nil config returns error", t, func() {
server, err := web.NewWebServer(nil)
So(err, ShouldNotBeNil)
So(server, ShouldBeNil)
})
}
func TestGetBindings(t *testing.T) {
Convey("WebServerConfiguration.GetBindings", t, func() {
Convey("returns only http binding when SSL port is 0", func() {
config := &web.WebServerConfiguration{Port: 8080}
So(config.GetBindings(), ShouldEqual, "http://0.0.0.0:8080")
})
Convey("returns https and http bindings when SSL port is set", func() {
config := &web.WebServerConfiguration{Port: 8080, SslPort: 8443}
So(config.GetBindings(), ShouldEqual, "https://0.0.0.0:8443,http://0.0.0.0:8080")
})
})
}
func TestBuildHostAddress(t *testing.T) {
Convey("WebServerConfiguration.BuildHostAddress", t, func() {
Convey("uses 0.0.0.0 when host is empty", func() {
config := &web.WebServerConfiguration{Port: 8080, SslPort: 8443}
So(config.BuildHostAddress(false), ShouldEqual, "0.0.0.0:8080")
So(config.BuildHostAddress(true), ShouldEqual, "0.0.0.0:8443")
})
Convey("uses configured host when set", func() {
config := &web.WebServerConfiguration{Host: "192.168.1.1", Port: 8080, SslPort: 8443}
So(config.BuildHostAddress(false), ShouldEqual, "192.168.1.1:8080")
So(config.BuildHostAddress(true), ShouldEqual, "192.168.1.1:8443")
})
})
}
func TestStartWithNoMounts(t *testing.T) {
Convey("Start returns error when no mounts are registered", t, func() {
port, err := GetFreePort()
So(err, ShouldBeNil)
config := &web.WebServerConfiguration{Port: port}
server, err := web.NewWebServer(config)
So(err, ShouldBeNil)
err = server.Start()
So(err, ShouldNotBeNil)
})
}
func TestNonSSLServer(t *testing.T) {
Convey("Non-SSL web server starts, serves requests, and stops", t, func() {
port, err := GetFreePort()
So(err, ShouldBeNil)
config := &web.WebServerConfiguration{Port: port}
server, err := web.NewWebServer(config)
So(err, ShouldBeNil)
server.Mount("/api", CreateTestRouter())
err = server.Start()
So(err, ShouldBeNil)
time.Sleep(100 * time.Millisecond)
uri := fmt.Sprintf("http://localhost:%d/health/readyz", port)
resp, err := http.Get(uri)
assert.Nil(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
So(err, ShouldBeNil)
So(resp.StatusCode, ShouldEqual, http.StatusOK)
body, _ := io.ReadAll(resp.Body)
healthData := web.HealthData{}
err = json.Unmarshal(body, &healthData)
assert.Nil(t, err)
assert.Equal(t, "service is ready", healthData.Message)
assert.NotEmpty(t, healthData.LastChecked)
So(err, ShouldBeNil)
So(healthData.Message, ShouldEqual, readyMessage)
server.Stop()
})
}
func TestUnhealthyServer(t *testing.T) {
Convey("Server with failing HealthCheck returns 503 from healthz endpoint", t, func() {
port, err := GetFreePort()
So(err, ShouldBeNil)
config := &web.WebServerConfiguration{Port: port}
server, err := web.NewWebServer(config)
So(err, ShouldBeNil)
server.HealthCheck = func() (bool, string) { return false, testFailureMsg }
server.Mount("/api", CreateTestRouter())
err = server.Start()
So(err, ShouldBeNil)
time.Sleep(100 * time.Millisecond)
uri := fmt.Sprintf("http://localhost:%d/health/healthz", port)
resp, err := http.Get(uri)
So(err, ShouldBeNil)
So(resp.StatusCode, ShouldEqual, http.StatusServiceUnavailable)
server.Stop()
})
}
func TestReady(t *testing.T) {
Convey("HTTP ready endpoint returns 200 with ready message", t, func() {
uri := getServerURL(false, "/readyz")
resp, err := http.Get(uri)
So(err, ShouldBeNil)
So(resp.StatusCode, ShouldEqual, http.StatusOK)
body, _ := io.ReadAll(resp.Body)
healthData := web.HealthData{}
err = json.Unmarshal(body, &healthData)
So(err, ShouldBeNil)
So(healthData.Message, ShouldEqual, readyMessage)
So(healthData.LastChecked, ShouldNotBeZeroValue)
})
}
func TestReadySSL(t *testing.T) {
Convey("HTTPS ready endpoint returns 200 with ready message", t, func() {
uri := getServerURL(true, "/health/readyz")
resp, err := http.Get(uri)
assert.Nil(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
So(err, ShouldBeNil)
So(resp.StatusCode, ShouldEqual, http.StatusOK)
body, _ := io.ReadAll(resp.Body)
healthData := web.HealthData{}
err = json.Unmarshal(body, &healthData)
assert.Nil(t, err)
assert.Equal(t, "service is ready", healthData.Message)
assert.NotEmpty(t, healthData.LastChecked)
So(err, ShouldBeNil)
So(healthData.Message, ShouldEqual, readyMessage)
So(healthData.LastChecked, ShouldNotBeZeroValue)
})
}
func TestHealthy(t *testing.T) {
Convey("Healthy endpoint returns 200 with running message", t, func() {
resp, err := http.Get(getServerURL(false, "/healthz"))
assert.Nil(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
So(err, ShouldBeNil)
So(resp.StatusCode, ShouldEqual, http.StatusOK)
body, _ := io.ReadAll(resp.Body)
healthData := web.HealthData{}
err = json.Unmarshal(body, &healthData)
assert.Nil(t, err)
assert.Equal(t, "service up and running", healthData.Message)
assert.NotEmpty(t, healthData.LastChecked)
So(err, ShouldBeNil)
So(healthData.Message, ShouldEqual, "service up and running")
So(healthData.LastChecked, ShouldNotBeZeroValue)
})
}
func TestSslEndpoint(t *testing.T) {
Convey("SSL endpoint returns 200 with expected data", t, func() {
uri := getServerURL(true, "/api/myendpoint")
resp, err := http.Get(uri)
assert.Nil(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
body, err := ioutil.ReadAll(resp.Body)
assert.Nil(t, err)
t.Logf("Body: %s", string(body))
So(err, ShouldBeNil)
So(resp.StatusCode, ShouldEqual, http.StatusOK)
body, err := io.ReadAll(resp.Body)
So(err, ShouldBeNil)
myData := MyData{}
err = json.Unmarshal(body, &myData)
assert.Nil(t, err)
assert.NotNil(t, myData)
assert.Equal(t, "A message", myData.Message)
assert.Greater(t, myData.Count, 0)
So(err, ShouldBeNil)
So(myData.Message, ShouldEqual, aMessage)
So(myData.Count, ShouldBeGreaterThan, 0)
})
}

View File

@ -22,6 +22,10 @@ import (
log "scm.yoorie.de/go-lib/gelf"
)
const (
errStartingServerLog = "error starting server: %s"
)
type WebServerConfiguration struct {
Host string `yaml:"host"`
Port int `default:"7080" yaml:"port"`
@ -202,26 +206,13 @@ func (server *WebServer) Start() error {
server.DebugRoutes("Main", server.router)
server.DebugRoutes("Health", server.healthRouter)
if ssl {
err := server.setupSsl()
if err != nil {
if err := server.setupSsl(); err != nil {
return err
}
} else {
// own http server for the healthchecks
httpAddress := server.serviceConfig.BuildHostAddress(false)
server.srv = &http.Server{
Addr: httpAddress,
WriteTimeout: time.Second * 15,
ReadTimeout: time.Second * 15,
IdleTimeout: time.Second * 60,
Handler: server.router,
if err := server.setupHttp(); err != nil {
return err
}
go func() {
log.Infof("Starting http server on address: %s", server.srv.Addr)
if err := server.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Alertf("error starting server: %s", err.Error())
}
}()
}
return nil
}
@ -239,7 +230,7 @@ func (server *WebServer) setupHttp() error {
go func() {
log.Infof("Starting http server on address: %s", server.srv.Addr)
if err := server.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Alertf("error starting server: %s", err.Error())
log.Alertf(errStartingServerLog, err.Error())
}
}()
return nil
@ -263,7 +254,7 @@ func (server *WebServer) setupSsl() error {
go func() {
log.Infof("Starting https server on address: %s", server.sslsrv.Addr)
if err := server.sslsrv.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed {
log.Alertf("error starting server: %s", err.Error())
log.Alertf(errStartingServerLog, err.Error())
}
}()
httpAddress := server.serviceConfig.BuildHostAddress(false)
@ -277,7 +268,7 @@ func (server *WebServer) setupSsl() error {
go func() {
log.Infof("Starting http server on address: %s", server.srv.Addr)
if err := server.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Alertf("error starting server: %s", err.Error())
log.Alertf(errStartingServerLog, err.Error())
}
}()
return nil
@ -296,9 +287,13 @@ func (server *WebServer) Stop() {
defer cancel()
log.Info("Shutting down server ...")
server.srv.Shutdown(ctx)
if err := server.srv.Shutdown(ctx); err != nil {
log.Alertf("error shutting down http server: %s", err.Error())
}
if server.isSsl() {
server.sslsrv.Shutdown(ctx)
if err := server.sslsrv.Shutdown(ctx); err != nil {
log.Alertf("error shutting down https server: %s", err.Error())
}
}
log.Info("Server has been shutted down")
}