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:
- Parse config file into current proto type
- Detect version mismatch (current=1, expected=2)
- Copy parsed data into v1 proto type
- Validate against v1 schema (pre-migration validation)
- Run typed migration function to transform v1 → v2
- 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
- Variables
- func GetVersion(msg proto.Message) (int, error)
- type Loader
- type Option
- type Parser
- type ParserRegistry
- type VersionedConfig
- func (*VersionedConfig) Descriptor() ([]byte, []int)deprecated
- func (x *VersionedConfig) GetName() string
- func (x *VersionedConfig) GetVersion() int32
- func (*VersionedConfig) ProtoMessage()
- func (x *VersionedConfig) ProtoReflect() protoreflect.Message
- func (x *VersionedConfig) Reset()
- func (x *VersionedConfig) String() string
Constants ¶
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 ¶
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.
var File_options_proto protoreflect.FileDescriptor
var File_versioned_test_proto protoreflect.FileDescriptor
Functions ¶
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 ¶
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 ¶
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:
- Read file from disk
- Detect format by extension
- Parse into proto message (with strict mode if enabled)
- Apply migration if registered and needed (includes pre-migration validation)
- 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 ¶
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 ¶
Option configures a Loader.
func WithMigration ¶
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:
- Parse config file into target type T
- Detect version mismatch (file is v(N-1), proto expects v(N))
- Copy parsed data into source type S
- Validate source message S (pre-migration validation)
- Call migration function: target = fn(source)
- 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 ¶
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