billwear.github.io

GoBear — Learning Go by Building a Bare‑Metal Provisioner

A public, release‑early, release‑often journey. The goal: learn Go while shaping a small, real network service that could grow into a bare‑metal provisioner.

Status: iterative; target pace ≈ 1 hour/day.

Project shape

Scope

Start as a general‑purpose network service with solid bones: structured logging, Prometheus metrics, health endpoints, clean shutdown, and a minimal API. Grow toward PXE/TFTP helpers and a small state store.

Why this order

Production‑shaped scaffolding first means every experiment runs inside sane boundaries. Learning Go and ops habits at the same time pays long‑term dividends.

Milestone 0 — A production‑shaped starter

Bootstrap a single binary that can log, expose metrics, respond to health checks, and shut down gracefully on SIGINT/SIGTERM.

go.mod

module github.com/yourname/gobear

go 1.22

require (
    github.com/prometheus/client_golang v1.18.0
)

cmd/gobear/main.go

package main

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"net/http"
	"os"
	"os/signal"
	"strconv"
	"syscall"
	"time"

	"github.com/prometheus/client_golang/prometheus/promhttp"
)

func main() {{
	// --- config ---
	addr := env("GOBEAR_ADDR", ":8080")
	readTimeout := dur("GOBEAR_READ_TIMEOUT", 5*time.Second)
	writeTimeout := dur("GOBEAR_WRITE_TIMEOUT", 10*time.Second)
	idleTimeout := dur("GOBEAR_IDLE_TIMEOUT", 60*time.Second)

	// --- logger ---
	logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{{Level: slog.LevelInfo}}))
	slog.SetDefault(logger)

	logger.Info("starting gobear", "addr", addr)

	// --- http mux ---
	mux := http.NewServeMux()
	mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {{
		w.WriteHeader(http.StatusOK)
		w.Write([]byte("ok"))
	}})
	mux.Handle("/metrics", promhttp.Handler())
	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {{
		fmt.Fprintf(w, "gobear: hello from %s\n", addr)
	}})

	server := &http.Server{{
		Addr:         addr,
		Handler:      logMiddleware(logger, mux),
		ReadTimeout:  readTimeout,
		WriteTimeout: writeTimeout,
		IdleTimeout:  idleTimeout,
	}}

	// --- run & graceful shutdown ---
	errCh := make(chan error, 1)
	go func() {{
		logger.Info("http.listen", "addr", addr)
		errCh <- server.ListenAndServe()
	}}()

	stop := make(chan os.Signal, 1)
	signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)

	select {{
	case sig := <-stop:
		logger.Info("signal", "sig", sig.String())
	case err := <-errCh:
		if !errors.Is(err, http.ErrServerClosed) {{
			logger.Error("http error", "error", err)
		}}
	}}

	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	if err := server.Shutdown(ctx); err != nil {{
		logger.Error("shutdown", "error", err)
	}} else {{
		logger.Info("clean shutdown")
	}}
}}

func env(key, def string) string {{
	if v := os.Getenv(key); v != "" {{
		return v
	}}
	return def
}}

func dur(key string, def time.Duration) time.Duration {{
	if v := os.Getenv(key); v != "" {{
		if n, err := time.ParseDuration(v); err == nil {{ return n }}
		if i, err := strconv.Atoi(v); err == nil {{ return time.Duration(i) * time.Second }}
	}}
	return def
}}

func logMiddleware(l *slog.Logger, next http.Handler) http.Handler {{
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {{
		start := time.Now()
		rw := &resp{{w, http.StatusOK}}
		next.ServeHTTP(rw, r)
		l.Info("http", "method", r.Method, "path", r.URL.Path, "status", rw.status, "t", time.Since(start).String())
	}})
}}

type resp struct{{
	http.ResponseWriter
	status int
}}

func (r *resp) WriteHeader(code int) {{
	r.status = code
	r.ResponseWriter.WriteHeader(code)
}}

Run it

go mod tidy
go run ./cmd/gobear
# in another shell:
curl -s localhost:8080/healthz
curl -s localhost:8080/
curl -s localhost:8080/metrics | head

What we have now

A clean, single binary with structured logs, metrics, health checks, and graceful shutdown — the core of any network service.

Milestone 1 — Configuration & layout

Keep the starter simple but ready to grow.

Version injection (snippet)

// inside main.go
var (
    version = "dev"
    commit  = "none"
    date    = "unknown"
)
logger.Info("build", "version", version, "commit", commit, "date", date)
# build with metadata
go build -ldflags "-X main.version=$(git describe --tags --always) -X main.commit=$(git rev-parse --short HEAD) -X main.date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" ./cmd/gobear

Milestone 2 — A tiny API for “provisioning”

Start with a simulated provisioning workflow that returns a token. No PXE yet — just shape the flow.

// add to mux in main()
mux.HandleFunc("/api/v1/provision", func(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodPost {
        w.WriteHeader(http.StatusMethodNotAllowed); return
    }
    token := fmt.Sprintf("pxe_%d", time.Now().Unix())
    w.Header().Set("Content-Type", "application/json")
    fmt.Fprintf(w, `{"token":"%s"}`, token)
})
# try it
curl -s -X POST localhost:8080/api/v1/provision

Why a fake first?

Design the request/response and logging paths before touching PXE/TFTP. It keeps learning focused and increments small.

Milestone 3 — Health, readiness, and metrics

// example readiness check
mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
    if _, err := os.Stat("state.db"); err == nil {
        w.Write([]byte("ready")); return
    }
    w.WriteHeader(http.StatusServiceUnavailable)
    w.Write([]byte("not ready"))
})

Milestone 4 — Next steps toward a provisioner

Release early, release often

Commit small, runnable changes. Keep a CHANGELOG. Tag “walkable” points even if features are thin. The habit matters more than the scope in the early days.

# quick quality loop
go test ./...
golangci-lint run   # if you add it
go build ./cmd/gobear
./gobear