config

package
v2.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Apr 20, 2026 License: MIT Imports: 14 Imported by: 0

README

LabKit v2 Config

Protobuf-first configuration management for GitLab Go services.

Overview

The config package provides a standardized way to load, validate, and migrate configuration files using Protocol Buffers as the single source of truth for configuration schemas. Validation rules are defined using protovalidate constraints directly in .proto files.

Features

  • Format autodetection: Automatically detects YAML, JSON, or TOML by file extension
  • Strong validation: Uses protovalidate for rich constraint validation (ranges, patterns, cross-field rules)
  • Rollback safety: Unknown fields silently ignored by default (forwards-compatible)
  • Version migrations: Automatic migration from version N-1 to N with pre/post-validation
  • Excellent error messages: File, line, column information for parse errors (YAML)
  • Extensible: Add custom format parsers via simple interface

Installation

# Install buf (if not using mise/asdf)
# See: https://buf.build/docs/installation

# Or with mise/asdf (recommended)
mise install

Quick Start

1. Define your configuration proto
// proto/config/v1/config.proto
edition = "2023";
package myapp.config.v1;

option go_package = "myapp/gen/config/v1;configv1";
option features.field_presence = IMPLICIT;

import "buf/validate/validate.proto";

message Config {
  int32 version = 1;
  ServerConfig server = 2 [(buf.validate.field).required = true];
}

message ServerConfig {
  string host = 1 [(buf.validate.field).string.min_bytes = 1];
  uint32 port = 2 [(buf.validate.field).uint32 = {
    gte: 1
    lte: 65535
  }];
}
2. Generate Go code
# Using buf directly (from v2/config directory)
cd v2/config
buf generate

# Or use the provided script for config module + examples (from repo root)
./scripts/generate-config-protos.sh
3. Load and validate configuration
package main

import (
    "context"
    "os"

    "gitlab.com/gitlab-org/labkit/v2/config"
    "gitlab.com/gitlab-org/labkit/v2/log"
    configv1 "myapp/gen/config/v1"
)

func main() {
    // Initialize structured logger
    logger := log.New()
    ctx := context.Background()

    loader, err := config.New()
    if err != nil {
        logger.ErrorContext(ctx, "Failed to create config loader", "error", err)
        os.Exit(1)
    }

    var cfg configv1.Config
    if err := loader.Load("config.yaml", &cfg); err != nil {
        logger.ErrorContext(ctx, "Failed to load config", "error", err, "path", "config.yaml")
        os.Exit(1)
    }

    // cfg is now loaded and validated - emit structured log
    logger.InfoContext(ctx, "Config loaded successfully",
        "server_host", cfg.Server.Host,
        "server_port", cfg.Server.Port,
        "version", cfg.Version)
}

Format Support

Always Available
  • YAML (.yaml, .yml) - Recommended for human-authored configs
  • JSON (.json) - Useful for machine-generated configs
Opt-in
  • TOML (.toml) - Add via WithParser(toml.NewTOMLParser())
import (
    "gitlab.com/gitlab-org/labkit/v2/config"
    "gitlab.com/gitlab-org/labkit/v2/config/toml"
)

loader, _ := config.New(
    config.WithParser(toml.NewTOMLParser()),
)

Validation

Validation uses protovalidate constraints defined inline in proto files.

Standard Constraints
message ServerConfig {
  string host = 1 [(buf.validate.field).string = {
    min_bytes: 1
    max_bytes: 255
  }];

  uint32 port = 2 [(buf.validate.field).uint32 = {
    gte: 1
    lte: 65535
  }];

  repeated string tags = 3 [(buf.validate.field).repeated.min_items = 1];
}
Cross-Field Validation with CEL
message TLSConfig {
  string cert_path = 1;
  string key_path = 2;

  option (buf.validate.message).cel = {
    id: "tls_pair"
    message: "cert_path and key_path must both be set or both be empty"
    expression: "(this.cert_path == '') == (this.key_path == '')"
  };
}

Migrations

Configure version migrations to automatically upgrade configs from version N-1 to N.

1. Define versioned protos

v1 config:

message Config {
  int32 version = 1;
  ServerConfig server = 2;
}

message ServerConfig {
  string host = 1;
  uint32 port = 2;
}

v2 config (renamed field):

message Config {
  int32 version = 1;
  ServerConfig server = 2;
}

message ServerConfig {
  string address = 1;  // renamed from "host"
  uint32 port = 2;
}
2. Write typed migration function
func migrateV1ToV2(source *configv1.Config) (*configv2.Config, error) {
    return &configv2.Config{
        Version: 2,
        Server: &configv2.ServerConfig{
            Address: source.Server.Host, // Renamed field
            Port:    source.Server.Port,
        },
    }, nil
}
3. Register migration
import (
    "context"
    "os"
    "gitlab.com/gitlab-org/labkit/v2/log"
)

logger := log.New()
ctx := context.Background()

loader, err := config.New(
    config.WithMigration(migrateV1ToV2),
)
if err != nil {
    logger.ErrorContext(ctx, "Failed to create loader with migration", "error", err)
    os.Exit(1)
}

var cfg configv2.Config
// If config file is v1, migration runs automatically
if err := loader.Load("config.yaml", &cfg); err != nil {
    logger.ErrorContext(ctx, "Failed to load config", "error", err)
    os.Exit(1)
}

logger.InfoContext(ctx, "Config loaded with migration",
    "version", cfg.Version,
    "migrated", cfg.Version > 1)
Migration Flow
  1. Parse config file into target proto (v2) to detect version
  2. Detect version mismatch (e.g., file has v1, proto expects v2)
  3. Re-parse config file into source type (v1)
  4. Pre-migration validation: Validate against v1 schema
  5. Run typed migration function to transform v1 → v2
  6. Post-migration validation: Validate against v2 schema

The migration function receives fully-typed, validated v1 config and returns v2 config. This provides type safety and makes migrations easier to write and test.

Strict Mode

By default, unknown fields are silently ignored (rollback-safe). Enable strict mode to catch typos:

loader, _ := config.New(config.WithStrictMode())
When to use strict mode

✓ Use in:

  • CI pipelines validating config examples
  • Pre-deployment validation
  • Development environments

✗ Don't use in:

  • Production deployments (breaks rollback safety)
  • Canary deployments (version skew)
Why rollback safety matters

If you deploy v2 with a new config field, then roll back to v1, the v1 binary will encounter an unknown field. With strict mode enabled, it crashes. With strict mode disabled (default), it ignores the field and continues.

Custom Parsers

Add support for custom formats by implementing the Parser interface:

type Parser interface {
    Extensions() []string
    Unmarshal(data []byte, path string, msg proto.Message, strict bool) error
}

Example: XML parser

type xmlParser struct{}

func (p *xmlParser) Extensions() []string {
    return []string{".xml"}
}

func (p *xmlParser) Unmarshal(
    data []byte, path string, msg proto.Message, strict bool,
) error {
    // Parse XML, convert to proto message
    return nil
}

// Register the parser
loader, _ := config.New(
    config.WithParser(&xmlParser{}),
)

API Reference

Constructors
// Create loader with default settings (YAML + JSON parsers)
func New(opts ...Option) (*Loader, error)
Options
// Add custom format parser
func WithParser(p Parser) Option

// Enable strict mode (reject unknown fields)
func WithStrictMode() Option

// Register migration from v(N-1) to v(N)
func WithMigration(fn MigrationFunc, oldProto proto.Message) Option
Methods
// Load config from file (format auto-detected)
func (l *Loader) Load(path string, msg proto.Message) error

// Load config from bytes with explicit format
func (l *Loader) LoadBytes(data []byte, ext string, msg proto.Message) error
Helpers
// Extract version field from proto message
func GetVersion(msg proto.Message) (int, error)

Best Practices

  1. Use YAML for human-authored configs - Better readability, comments supported
  2. Use YAML or JSON for machine-generated configs - Simpler, no ambiguity
  3. Keep migrations simple - Only support N-1 → N, not arbitrary jumps
  4. Use protovalidate for all constraints - Single source of truth
  5. Test config examples in CI with strict mode - Catch typos early
  6. Never use strict mode in production - Breaks rollback safety

Example

See go-service-template for a complete working example demonstrating config management in a production service.

Documentation

Overview

Package config provides protobuf-first configuration management for LabKit v2.

The config package offers a standardized way to load, validate, and migrate configuration files across GitLab's Go services. It uses Protocol Buffers as the single source of truth for configuration schemas, with validation rules defined using protovalidate constraints.

Quick Start

Define your configuration as a proto message with protovalidate constraints:

// proto/config/v1/config.proto
edition = "2023";
package myapp.config.v1;

option features.field_presence = IMPLICIT;

import "options.proto";
import "buf/validate/validate.proto";

message Config {
  option (gitlab.labkit.config.expected_version) = 1;

  int32 version = 1;
  ServerConfig server = 2 [(buf.validate.field).required = true];
}

message ServerConfig {
  string host = 1 [(buf.validate.field).string.min_len = 1];
  uint32 port = 2 [(buf.validate.field).uint32 = {gte: 1, lte: 65535}];
}

Load and validate configuration:

import (
    "context"
    "os"
    "gitlab.com/gitlab-org/labkit/v2/config"
    "gitlab.com/gitlab-org/labkit/v2/log"
)

logger := log.New()
ctx := context.Background()

loader, err := config.New()
if err != nil {
    logger.ErrorContext(ctx, "Failed to create config loader", "error", err)
    os.Exit(1)
}

var cfg configpb.Config
if err := loader.Load("config.yaml", &cfg); err != nil {
    logger.ErrorContext(ctx, "Failed to load config", "error", err, "path", "config.yaml")
    os.Exit(1)
}

logger.InfoContext(ctx, "Config loaded successfully", "version", cfg.Version)

Format Support

The loader automatically detects configuration format by file extension:

  • YAML (.yaml, .yml) - always available, recommended for human-authored configs
  • JSON (.json) - always available, useful for machine-generated configs
  • TOML (.toml) - opt-in via WithParser(toml.NewTOMLParser())

YAML is the preferred format for configuration files as it's familiar to operators, supports comments, and aligns with Kubernetes and Helm chart conventions.

Validation

Validation is performed using protovalidate (https://github.com/bufbuild/protovalidate) which provides rich constraint expressions using CEL (Common Expression Language).

Standard constraints:

string host = 1 [(buf.validate.field).string = {min_len: 1, max_len: 255}];
uint32 port = 2 [(buf.validate.field).uint32 = {gte: 1, lte: 65535}];
repeated string tags = 3 [(buf.validate.field).repeated = {min_items: 1}];

Cross-field validation with CEL:

message TLSConfig {
  string cert_path = 1;
  string key_path = 2;

  option (buf.validate.message).cel = {
    id: "tls_pair"
    message: "cert_path and key_path must both be set or both be empty"
    expression: "(this.cert_path == '') == (this.key_path == '')"
  };
}

Migrations

Configuration versions are declared using the expected_version proto option. The loader can automatically migrate from version N-1 to version N:

// v2 config expects version 2
message Config {
  option (gitlab.labkit.config.expected_version) = 2;
  int32 version = 1;
  ServerConfig server = 2;
}

// Migration function (typed, generic)
func migrateV1ToV2(source *configv1.Config) (*configv2.Config, error) {
    return &configv2.Config{
        Version: 2,
        Server: &configv2.ServerConfig{
            Address: source.Server.Host, // Renamed field
            Port:    source.Server.Port,
            Timeout: durationpb.New(30 * time.Second), // New field with default
        },
        Logging: source.Logging,
    }, nil
}

// Register typed migration
loader, _ := config.New(
    config.WithMigration(migrateV1ToV2),
)

Migration flow:

  1. Parse config file into current proto type
  2. Detect version mismatch (current=1, expected=2)
  3. Copy parsed data into v1 proto type
  4. Validate against v1 schema (pre-migration validation)
  5. Run typed migration function to transform v1 → v2
  6. Validate result against v2 schema (post-migration validation)

Strict Mode

By default, unknown fields in config files are silently ignored. This is rollback-safe: if you deploy v2 with new fields, then roll back to v1, the v1 binary won't crash on the v2 config.

Strict mode rejects unknown fields, useful for catching typos in CI:

loader, _ := config.New(config.WithStrictMode())

Use strict mode in:

  • CI pipelines validating config examples
  • Pre-deployment validation
  • Development environments

Do NOT use strict mode in:

  • Production deployments (breaks rollback safety)
  • Canary deployments (may have version skew)

Custom Parsers

Add support for additional formats using WithParser:

import "gitlab.com/gitlab-org/labkit/v2/config/toml"

loader, _ := config.New(
    config.WithParser(toml.NewTOMLParser()),
)

Implement the Parser interface to add custom formats:

type Parser interface {
    Extensions() []string
    Unmarshal(data []byte, path string, msg proto.Message, strict bool) error
}

Error Handling

Parse errors include file path, line, and column information (YAML):

config.yaml:5:3: invalid ServerConfig.port: value must be <= 65535 but got 99999

Validation errors reference field paths and constraints:

validation failed: invalid Config.server: embedded message failed validation |
  caused by: invalid ServerConfig.port: value must be greater than or equal to 1

Migration errors include version context:

migration from version 1 to 2 failed: server.host field is required

Index

Constants

View Source
const (
	// DefaultVersion is used when no version field is present in the config.
	DefaultVersion = 1

	// VersionFieldName is the expected name of the version field in proto messages.
	VersionFieldName = "version"
)

Variables

View Source
var (
	// expected_version declares the version this config schema expects.
	// Used by the config loader to determine if migration is needed.
	//
	// Example:
	//
	//	message Config {
	//	  option (gitlab.labkit.config.expected_version) = 2;
	//	  int32 version = 1;
	//	  // ... other fields
	//	}
	//
	// If not set, defaults to 1.
	//
	// optional int32 expected_version = 50001;
	E_ExpectedVersion = &file_options_proto_extTypes[0]
)

Extension fields to descriptorpb.MessageOptions.

View Source
var File_options_proto protoreflect.FileDescriptor
View Source
var File_versioned_test_proto protoreflect.FileDescriptor

Functions

func GetVersion

func GetVersion(msg proto.Message) (int, error)

GetVersion extracts the version field from a proto message. Returns DefaultVersion if the field is not present or is zero.

Types

type Loader

type Loader struct {
	// contains filtered or unexported fields
}

Loader loads and validates protobuf-based configuration. Construct with New() and optional functional options.

func New

func New(opts ...Option) (*Loader, error)

New returns a Loader with default settings.

YAML and JSON parsers are always available.

Example:

loader, err := config.New()
loader, err := config.New(config.WithStrictMode())
loader, err := config.New(
    config.WithParser(toml.NewTOMLParser()),
    config.WithMigration(migrateV1ToV2),
)

func (*Loader) Load

func (l *Loader) Load(path string, msg proto.Message) error

Load reads and parses a configuration file. Format is detected by file extension (.yaml, .yml, .json, etc.).

If a migration is registered and the config version is N-1 while the proto schema expects version N, the migration is applied automatically.

Loading flow:

  1. Read file from disk
  2. Detect format by extension
  3. Parse into proto message (with strict mode if enabled)
  4. Apply migration if registered and needed (includes pre-migration validation)
  5. Validate final result with protovalidate

Example:

var cfg examplepb.Config
if err := loader.Load("config.yaml", &cfg); err != nil {
    log.Fatal(err)
}

func (*Loader) LoadBytes

func (l *Loader) LoadBytes(data []byte, ext string, msg proto.Message) error

LoadBytes loads configuration from raw bytes with explicit format. The ext parameter should be a file extension like ".yaml", ".json", or ".toml".

This method is useful when config data comes from a non-file source (e.g., embedded data, network, database) and the format is known.

Example:

var cfg examplepb.Config
if err := loader.LoadBytes(yamlData, ".yaml", &cfg); err != nil {
    log.Fatal(err)
}

type Option

type Option func(*Loader) error

Option configures a Loader.

func WithMigration

func WithMigration[S, T proto.Message](fn func(S) (T, error)) Option

WithMigration registers a typed migration function from version N-1, type S, to version N, type T.

The migration function receives a fully parsed and validated source message of type S (the old version) and must return a transformed target message of type T (the new version).

Only one migration function can be registered per Loader. If you need to support multiple version jumps (e.g., v1→v2 and v2→v3), implement the multi-step logic inside your migration function.

The migration function is called automatically during Load() if the configuration version is N-1 and your proto schema expects version N.

Migration flow:

  1. Parse config file into target type T
  2. Detect version mismatch (file is v(N-1), proto expects v(N))
  3. Copy parsed data into source type S
  4. Validate source message S (pre-migration validation)
  5. Call migration function: target = fn(source)
  6. Validate target message T (post-migration validation)

Example:

func migrateV1ToV2(source *configv1.Config) (*configv2.Config, error) {
    target := &configv2.Config{
        Version: 2,
        Server: &configv2.ServerConfig{
            Address: source.Server.Host,  // Renamed field
            Port:    source.Server.Port,
            Timeout: durationpb.New(30 * time.Second), // New field with default
        },
        Logging: source.Logging,
    }
    return target, nil
}

loader, _ := config.New(
    config.WithMigration(migrateV1ToV2),
)

Panics if:

  • fn is nil
  • migration already registered (only one migration allowed)

func WithParser

func WithParser(p Parser) Option

WithParser registers an additional parser.

Can be called multiple times to add support for multiple formats. YAML and JSON are always available by default.

Example:

loader, _ := config.New(
    config.WithParser(toml.NewTOMLParser()),
    config.WithParser(&customParser{}),
)

func WithStrictMode

func WithStrictMode() Option

WithStrictMode enables strict validation, rejecting unknown fields.

Use this in CI/testing to catch typos, not in production, as it breaks rollback safety.

In strict mode, config files with fields not defined in the proto schema will be rejected with an error. This helps catch typos and obsolete configuration during validation, but prevents rolling back to older service versions that don't recognize newly added fields.

Default: false - unknown fields are silently ignored for rollback safety.

type Parser

type Parser interface {
	// Extensions returns the file extensions this parser handles (e.g., []string{".yaml", ".yml"}).
	// Extensions should include the leading dot.
	Extensions() []string

	// Unmarshal parses data into the proto message.
	// The path parameter is optional and used for error reporting to provide
	// file location context.
	// The strict parameter controls whether unknown fields should be rejected (true)
	// or silently ignored (false). Strict mode is useful for CI/validation but
	// breaks rollback safety in production.
	Unmarshal(data []byte, path string, msg proto.Message, strict bool) error
}

Parser defines the interface for format-specific configuration parsers. Implementations handle parsing config files in different formats (YAML, JSON, TOML, etc.) into protobuf messages.

type ParserRegistry

type ParserRegistry struct {
	// contains filtered or unexported fields
}

ParserRegistry manages registered format parsers. Not directly exposed to users - they interact via WithParser() option.

func (*ParserRegistry) DetectFormat

func (r *ParserRegistry) DetectFormat(path string) (Parser, error)

DetectFormat returns the parser for the given file path.

func (*ParserRegistry) Get

func (r *ParserRegistry) Get(ext string) (Parser, bool)

Get returns the parser for the given extension.

func (*ParserRegistry) Register

func (r *ParserRegistry) Register(p Parser)

Register adds a parser to this registry. Panics if an extension is already registered (programming error).

type VersionedConfig

type VersionedConfig struct {
	Version int32  `protobuf:"varint,1,opt,name=version" json:"version,omitempty"`
	Name    string `protobuf:"bytes,2,opt,name=name" json:"name,omitempty"`
	// contains filtered or unexported fields
}

VersionedConfig is used in tests to exercise the expected_version option.

func (*VersionedConfig) Descriptor deprecated

func (*VersionedConfig) Descriptor() ([]byte, []int)

Deprecated: Use VersionedConfig.ProtoReflect.Descriptor instead.

func (*VersionedConfig) GetName

func (x *VersionedConfig) GetName() string

func (*VersionedConfig) GetVersion

func (x *VersionedConfig) GetVersion() int32

func (*VersionedConfig) ProtoMessage

func (*VersionedConfig) ProtoMessage()

func (*VersionedConfig) ProtoReflect

func (x *VersionedConfig) ProtoReflect() protoreflect.Message

func (*VersionedConfig) Reset

func (x *VersionedConfig) Reset()

func (*VersionedConfig) String

func (x *VersionedConfig) String() string

Directories

Path Synopsis

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL