2 Commits

Author SHA1 Message Date
3c8c83caa8 test commit to kick off new ci settings
All checks were successful
check / check (push) Successful in 13s
check / check (pull_request) Successful in 1m6s
2026-03-02 21:08:05 +01:00
4abd40d8e2 fix: split Dockerfile with pinned images and add CI workflow (#14)
## Summary

Rewrites the Dockerfile to use sha256-pinned images and proper multi-stage build structure. Adds missing Makefile targets and a Gitea CI workflow.

## Changes

### Dockerfile
- **Lint stage**: `golangci/golangci-lint` v1.64.8 pinned by sha256 — runs `make fmt-check` + `make lint`
- **Test stage**: `golang` 1.22.12 pinned by sha256 — runs `make test` with dependency on lint stage
- Removed redundant final stage (this is a library with no binary to build)
- Both images pinned by digest with version+date comments

### Makefile
- Added `fmt-check` target: verifies `gofmt` compliance without modifying files
- Added `check` target: runs `fmt-check`, `lint`, `test` in sequence
- Added `hooks` target: installs a pre-commit hook that runs `make check`
- Separated `gofmt` check from `lint` target (was previously bundled)
- Changed default target from `test` to `check`

### CI
- Added `.gitea/workflows/check.yml`: runs `docker build .` on push to main and on PRs

## Verification

`docker build --progress plain .` passes — all stages complete successfully.

closes #9

<!-- session: agent:sdlc-manager:subagent:fffa0a5a-5127-4489-a2e0-314c5eaaed68 -->

Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Reviewed-on: #14
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-02 21:06:53 +01:00
6 changed files with 46 additions and 385 deletions

View File

@@ -0,0 +1,12 @@
name: check
on:
push:
pull_request:
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- run: docker build .

View File

@@ -1,39 +1,20 @@
# First stage: Use the golangci-lint image to run the linter
FROM golangci/golangci-lint:latest as lint
# Set the Current Working Directory inside the container
WORKDIR /app
# Copy the go.mod file and the rest of the application code
COPY go.mod ./
# Lint stage: format check + golangci-lint
# golangci-lint v1.64.8 (2025-02-18)
FROM golangci/golangci-lint@sha256:2987913e27f4eca9c8a39129d2c7bc1e74fbcf77f181e01cea607be437aa5cb8 AS lint
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN make fmt-check
RUN make lint
# Run golangci-lint
RUN golangci-lint run
RUN sh -c 'test -z "$(gofmt -l .)"'
# Second stage: Use the official Golang image to run tests
FROM golang:1.22 as test
# Set the Current Working Directory inside the container
WORKDIR /app
# Copy the go.mod file and the rest of the application code
COPY go.mod ./
# Test stage: run full test suite
# golang 1.22.12 (2025-02-04)
FROM golang@sha256:1cf6c45ba39db9fd6db16922041d074a63c935556a05c5ccb62d181034df7f02 AS test
# Depend on lint stage so both stages always run
COPY --from=lint /src/go.sum /dev/null
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Run tests
RUN go test -v ./...
# Final stage: Combine the linting and testing stages
FROM golang:1.22 as final
# Ensure that the linting stage succeeded
WORKDIR /app
COPY --from=lint /app .
COPY --from=test /app .
# Set the final CMD to something minimal since we only needed to verify lint and tests during build
CMD ["echo", "Build and tests passed successfully!"]
RUN make test

View File

@@ -1,6 +1,6 @@
.PHONY: test
.PHONY: test fmt fmt-check lint check docker hooks
default: test
default: check
test:
@go test -v ./...
@@ -9,9 +9,20 @@ fmt:
goimports -l -w .
golangci-lint run --fix
fmt-check:
@test -z "$$(gofmt -l .)" || { echo "gofmt would reformat:"; gofmt -l .; exit 1; }
lint:
golangci-lint run
sh -c 'test -z "$$(gofmt -l .)"'
check: fmt-check lint test
docker:
docker build --progress plain .
hooks:
@echo "Installing git hooks..."
@mkdir -p .git/hooks
@printf '#!/bin/sh\nmake check\n' > .git/hooks/pre-commit
@chmod +x .git/hooks/pre-commit
@echo "Pre-commit hook installed."

View File

@@ -19,18 +19,9 @@ Released v1.0.0 2024-06-14. Works as intended. No known bugs.
- if output is not a tty, outputs json
- supports delivering each log message via a webhook
## RELP Delivery
## Planned Features
To deliver logs via RELP to a remote rsyslog server (using `imrelp`),
set the `LOGGER_RELP_URL` environment variable:
```bash
export LOGGER_RELP_URL=tcp://rsyslog.example.com:2514
```
Messages are formatted as RFC 5424 syslog and delivered reliably with
per-message acknowledgement. The connection is established lazily on
first log and reconnects automatically on failure.
- supports delivering logs via tcp RELP (e.g. to remote rsyslog using imrelp)
## Installation
@@ -72,3 +63,4 @@ func main() {
## License
[WTFPL](./LICENSE)

View File

@@ -1,327 +0,0 @@
package simplelog
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net"
"net/url"
"os"
"strconv"
"strings"
"sync"
"time"
)
const (
relpVersion = "1"
relpSoftware = "simplelog,1.0.0,https://sneak.berlin/go/simplelog"
relpDefaultTimeout = 5 * time.Second
relpMaxTxnr = 999_999_999
)
// RELPHandler delivers log messages to a remote rsyslog server
// using the RELP (Reliable Event Logging Protocol).
type RELPHandler struct {
mu sync.Mutex
address string
conn net.Conn
txnr int
}
// NewRELPHandler creates a handler that sends logs via RELP.
// The relpURL should be in the form "tcp://host:port".
func NewRELPHandler(relpURL string) (*RELPHandler, error) {
u, err := url.Parse(relpURL)
if err != nil {
return nil, fmt.Errorf("invalid RELP URL: %w", err)
}
scheme := u.Scheme
if scheme == "" {
scheme = "tcp"
}
if scheme != "tcp" {
return nil, fmt.Errorf(
"unsupported RELP scheme %q, only tcp is supported",
scheme,
)
}
host := u.Host
if host == "" {
return nil, fmt.Errorf("RELP URL must include a host")
}
if _, _, err := net.SplitHostPort(host); err != nil {
host = net.JoinHostPort(host, "2514")
}
h := &RELPHandler{
address: host,
}
return h, nil
}
func (h *RELPHandler) Enabled(
_ context.Context,
_ slog.Level,
) bool {
return true
}
func (h *RELPHandler) WithAttrs(_ []slog.Attr) slog.Handler {
return h
}
func (h *RELPHandler) WithGroup(_ string) slog.Handler {
return h
}
func (h *RELPHandler) Handle(
_ context.Context,
record slog.Record,
) error {
h.mu.Lock()
defer h.mu.Unlock()
if err := h.ensureConnected(); err != nil {
return fmt.Errorf("relp connect: %w", err)
}
msg := h.formatSyslog(record)
if err := h.sendSyslog(msg); err != nil {
// Connection may be broken; close and let next call
// reconnect.
h.closeConn()
return fmt.Errorf("relp syslog: %w", err)
}
return nil
}
// ensureConnected dials and performs the RELP open handshake if
// no connection exists.
func (h *RELPHandler) ensureConnected() error {
if h.conn != nil {
return nil
}
conn, err := net.DialTimeout("tcp", h.address, relpDefaultTimeout)
if err != nil {
return err
}
h.conn = conn
h.txnr = 0
return h.open()
}
// open sends the RELP "open" command and reads the server's
// response.
func (h *RELPHandler) open() error {
offers := fmt.Sprintf(
"relp_version=%s\nrelp_software=%s\ncommands=syslog",
relpVersion,
relpSoftware,
)
if err := h.sendFrame("open", []byte(offers)); err != nil {
return fmt.Errorf("open send: %w", err)
}
_, _, err := h.readResponse()
if err != nil {
return fmt.Errorf("open rsp: %w", err)
}
return nil
}
// sendSyslog sends a single syslog message and waits for the
// acknowledgement.
func (h *RELPHandler) sendSyslog(msg []byte) error {
if err := h.sendFrame("syslog", msg); err != nil {
return err
}
code, _, err := h.readResponse()
if err != nil {
return err
}
if code != 200 {
return fmt.Errorf("server returned status %d", code)
}
return nil
}
// Close gracefully shuts down the RELP session.
func (h *RELPHandler) Close() error {
h.mu.Lock()
defer h.mu.Unlock()
if h.conn == nil {
return nil
}
// Best-effort close command.
_ = h.sendFrame("close", nil)
_, _, _ = h.readResponse()
return h.closeConn()
}
func (h *RELPHandler) closeConn() error {
if h.conn == nil {
return nil
}
err := h.conn.Close()
h.conn = nil
h.txnr = 0
return err
}
// nextTxnr returns the next transaction number.
func (h *RELPHandler) nextTxnr() int {
h.txnr++
if h.txnr > relpMaxTxnr {
h.txnr = 1
}
return h.txnr
}
// sendFrame writes a RELP frame to the connection.
// Frame format: TXNR SP COMMAND SP DATALEN [SP DATA] LF
func (h *RELPHandler) sendFrame(
command string,
data []byte,
) error {
txnr := h.nextTxnr()
dataLen := len(data)
var frame []byte
header := fmt.Sprintf("%d %s %d", txnr, command, dataLen)
if dataLen > 0 {
frame = make([]byte, 0, len(header)+1+dataLen+1)
frame = append(frame, header...)
frame = append(frame, ' ')
frame = append(frame, data...)
} else {
frame = make([]byte, 0, len(header)+1)
frame = append(frame, header...)
}
frame = append(frame, '\n')
_ = h.conn.SetWriteDeadline(
time.Now().Add(relpDefaultTimeout),
)
_, err := h.conn.Write(frame)
return err
}
// readResponse reads a RELP response frame from the connection.
// Returns the status code and any extra data.
func (h *RELPHandler) readResponse() (int, string, error) {
_ = h.conn.SetReadDeadline(
time.Now().Add(relpDefaultTimeout),
)
// Read until we hit LF. RELP frames are terminated by LF.
buf := make([]byte, 0, 1024)
one := make([]byte, 1)
for {
n, err := h.conn.Read(one)
if err != nil {
return 0, "", fmt.Errorf("read: %w", err)
}
if n == 0 {
continue
}
if one[0] == '\n' {
break
}
buf = append(buf, one[0])
if len(buf) > 128*1024 {
return 0, "", fmt.Errorf("response too large")
}
}
// Parse: TXNR SP "rsp" SP DATALEN [SP DATA]
frame := string(buf)
// Skip TXNR
parts := strings.SplitN(frame, " ", 4)
if len(parts) < 3 {
return 0, "", fmt.Errorf("malformed rsp frame: %q", frame)
}
dataLen, err := strconv.Atoi(parts[2])
if err != nil {
return 0, "", fmt.Errorf("bad datalen: %w", err)
}
var rspData string
if dataLen > 0 && len(parts) >= 4 {
rspData = parts[3]
}
// rspData format: STATUS SP HUMANTEXT [LF EXTRA]
if rspData == "" {
return 200, "", nil
}
statusStr := rspData
rest := ""
if idx := strings.IndexByte(rspData, ' '); idx >= 0 {
statusStr = rspData[:idx]
rest = rspData[idx+1:]
}
code, err := strconv.Atoi(statusStr)
if err != nil {
return 0, "", fmt.Errorf("bad status code %q: %w", statusStr, err)
}
return code, rest, nil
}
// formatSyslog formats a slog.Record as an RFC 5424 syslog
// message.
func (h *RELPHandler) formatSyslog(record slog.Record) []byte {
// Map slog levels to syslog severity.
var severity int
switch {
case record.Level >= slog.LevelError:
severity = 3 // err
case record.Level >= slog.LevelWarn:
severity = 4 // warning
case record.Level >= slog.LevelInfo:
severity = 6 // info
default:
severity = 7 // debug
}
// Facility 1 = user-level
priority := 1*8 + severity
hostname, _ := os.Hostname()
if hostname == "" {
hostname = "-"
}
ts := record.Time.UTC().Format(time.RFC3339Nano)
// Collect structured data from attributes.
attrs := make(map[string]string)
record.Attrs(func(a slog.Attr) bool {
attrs[a.Key] = a.Value.String()
return true
})
var sd string
if len(attrs) > 0 {
jsonBytes, _ := json.Marshal(attrs)
sd = string(jsonBytes)
} else {
sd = "-"
}
msg := fmt.Sprintf(
"<%d>1 %s %s simplelog - - %s %s",
priority,
ts,
hostname,
sd,
record.Message,
)
return []byte(msg)
}

View File

@@ -15,7 +15,6 @@ import (
var (
webhookURL = os.Getenv("LOGGER_WEBHOOK_URL")
relpURL = os.Getenv("LOGGER_RELP_URL")
)
var ourCustomLogger *slog.Logger
@@ -45,13 +44,6 @@ func NewMultiplexHandler() slog.Handler {
}
cl.handlers = append(cl.handlers, handler)
}
if relpURL != "" {
handler, err := NewRELPHandler(relpURL)
if err != nil {
log.Fatalf("Failed to initialize RELP handler: %v", err)
}
cl.handlers = append(cl.handlers, handler)
}
return cl
}