licenses_core

package module
v1.0.1 Latest Latest
Warning

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

Go to latest
Published: Jul 12, 2026 License: Apache-2.0 Imports: 28 Imported by: 0

README

licenses-exporter-core

The vendor-neutral engine for the licenses_exporter family: the generic license_ metric schema and its constructors, an immutable snapshot store, the collection loop, the Prometheus + OTLP export paths, and the HTTP server with cancelable, validated hot reload.

Vendor exporters (VMware, Microsoft 365, Veeam, …) import this module, implement a single Source, and call Main. Because every vendor emits the identical license_ schema — built only through this module's constructors — N exporters land in one Prometheus and keep a single cross-vendor Grafana / alerting view.

import core "github.com/fjacquet/licenses-exporter-core"
  • Schema identity is guaranteed by construction: Samples are built only by core constructors, and a golden test locks each metric name's label-key set.
  • Library, not a binary. This module ships no main, no release artifacts, no Docker image — those live in each consuming vendor exporter.

Consumer contract

A vendor exporter provides three things: a Source implementation, a config struct embedding core.Base, and a thin main that hands core.Main an App.

1. Implement Source — one per configured tenant/instance:

type Source interface {
    Vendor() string   // constant, e.g. "microsoft"
    Instance() string // tenant / vCenter identifier
    Collect(ctx context.Context) ([]core.Sample, error)
}

Build every Sample through a core constructor (core.SeatSample, core.ExpirationSample, …) — never a raw literal. That is what keeps the license_ schema identical across vendors.

2. Embed core.Base in the vendor config:

type Config struct {
    core.Base `yaml:",inline"`               // collection.interval + otlp.{endpoint,insecure}
    M365      M365Config `yaml:"m365"`        // vendor-specific block
}

3. A ~30-line main — parse flags (cobra/pflag/stdlib, your choice), then build an App whose Load re-parses config and rebuilds sources. core.Main runs the whole lifecycle (--once, or serve /metrics + /health with signal + file-watch hot reload):

func main() {
    var cfgPath, addr string
    var once, debug, trace bool
    // ... flag wiring (--config, --web.listen-address, --once, --debug, --trace) ...

    err := core.Main(core.App{
        Version: version, Addr: addr, Once: once, Debug: debug, Trace: trace,
        ConfigPath: cfgPath, // enables file-watch reload; empty => SIGHUP-only
        Load: func() (core.Base, []core.Source, error) {
            var cfg Config
            if err := core.LoadYAML(cfgPath, &cfg); err != nil {
                return core.Base{}, nil, err
            }
            if err := cfg.Base.Validate(); err != nil {
                return core.Base{}, nil, err
            }
            srcs, err := m365.NewSources(cfg.M365) // returns []core.Source
            return cfg.Base, srcs, err
        },
    })
    if err != nil {
        logrus.WithError(err).Fatal("exporter failed")
    }
}

Load is called at startup and on every reload, so vendor-config changes hot-reload too. core.Main builds the serving stack once and swaps only the collection loop on reload — /metrics never blanks and the socket never rebinds.

Versioning

Semantic versioning. v1.0.0 — the public API is stable, validated by two independent consumers (m365_licenses_exporter and vmware_licenses_exporter) compiling against it unchanged. The v0.1.x line was the API-settling window against the first consumer.

License

Apache-2.0 — see LICENSE.

Documentation

Overview

Package licenses_core is the vendor-neutral engine for the licenses_exporter family: the license_ metric schema and its constructors, an immutable snapshot store, the collection loop, the Prometheus + OTLP export paths, and the HTTP server with cancelable validated hot reload. Vendor exporters implement Source and call Main.

Index

Constants

View Source
const (
	MetricSeatsTotal     = "license_seats_total"
	MetricSeatsUsed      = "license_seats_used"
	MetricExpiration     = "license_expiration_timestamp_seconds"
	MetricUp             = "license_up"
	MetricLastSuccess    = "license_collector_last_success_timestamp_seconds"
	MetricScrapeDuration = "license_scrape_duration_seconds"
	MetricBuildInfo      = "license_build_info"
)

Metric names. Every metric is prefixed license_ (design spec §4).

Variables

This section is empty.

Functions

func Expand

func Expand(s string) (string, error)

Expand replaces ${VAR} references, failing on any unset variable.

func LoadDotEnv

func LoadDotEnv(cfgPath string)

LoadDotEnv loads a .env from the CWD, then from the config file's directory, BEFORE ${ENV} interpolation. godotenv never overrides an already-set variable, so real secret injection always wins. Missing .env files are ignored.

func LoadYAML

func LoadYAML(path string, into any) error

LoadYAML reads .env, expands ${ENV} references, and unmarshals the resulting YAML into into. It does not validate; callers run Base.Validate (and any vendor-specific validation) after LoadYAML returns.

func Main

func Main(app App) error

Main runs the whole exporter lifecycle for App.

--once path: one Load, one RunOnce (dumping samples iff Debug), then return — no server is started.

Serve path: Load once, build the process-lifetime server (NewServer), wire OS signals and an optional fsnotify watcher into ReloadLoop's plain reloads/shutdown channels via signalAdapter, then block in ReloadLoop until a shutdown trigger. Only the initial Load and the initial NewServer bind are fatal (startup); once serving, a reload failure is logged and skipped — it never crashes the process. Flag/config-format parsing stays in the CONSUMER: Main takes an already-parsed App.

func RegisterOTLP

func RegisterOTLP(meter metric.Meter, store *SnapshotStore) error

RegisterOTLP registers one observable gauge per metric name. Each callback reads the current snapshot and observes its matching samples at OBSERVATION time (points are not back-dated; data age is carried by license_collector_last_success_timestamp_seconds).

func ResolveSecret

func ResolveSecret(inline, file string) (string, error)

ResolveSecret returns the secret read from file (trimmed of surrounding whitespace) when file is set, otherwise the inline value. Shared by the vendor collectors so inline-vs-file precedence stays consistent across them.

func Resource

func Resource(version, instanceID string) *resource.Resource

Resource builds the OTLP resource attributes for the exporter.

func RunOnce

func RunOnce(ctx context.Context, sources []Source, version string, debug bool) error

RunOnce runs a single collection cycle against a throwaway store — the --once path. Samples are dumped (sorted, exposition style) ONLY when debug is true. No server is started.

Types

type App

type App struct {
	Version    string // build version, stamped into license_build_info
	Addr       string // --web.listen-address, e.g. ":9105"
	Once       bool   // --once: run one collection cycle then exit (no server)
	Debug      bool   // gates the --once sample dump; also sets logrus debug level
	Trace      bool   // logs a generic "SDK tracing intentionally not wired" warning
	ConfigPath string // path watched for hot reload; empty => reload via SIGHUP only
	Load       func() (Base, []Source, error)
}

App is what a vendor main assembles and hands to Main. Load re-parses the vendor's whole config (Base + vendor collector block) and rebuilds the []Source; Main calls it once at startup (--once and serve paths alike) and ReloadLoop calls it again on every SIGHUP / config-file-change reload.

type Base

type Base struct {
	Collection CollectionConfig `yaml:"collection"`
	OTLP       OTLPConfig       `yaml:"otlp"`
}

Base is the vendor-neutral configuration shared by every exporter built on this engine: the collection loop cadence and the optional OTLP push exporter. Vendor exporters embed Base alongside their own collector config.

func (Base) Validate

func (b Base) Validate() error

Validate checks the base-level invariants only; vendor collector config (e.g. "at least one collector enabled") is validated by the caller.

type CollectionConfig

type CollectionConfig struct {
	Interval time.Duration `yaml:"interval"`
}

CollectionConfig configures the collection loop's polling cadence.

func (*CollectionConfig) UnmarshalYAML

func (c *CollectionConfig) UnmarshalYAML(unmarshal func(interface{}) error) error

UnmarshalYAML lets collection.interval accept a Go duration string ("2h").

type Collector

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

Collector runs the background collection loop and publishes snapshots.

func NewCollector

func NewCollector(sources []Source, store *SnapshotStore, version, goVersion string, limit int, now func() time.Time) *Collector

func (*Collector) CollectOnce

func (c *Collector) CollectOnce(ctx context.Context) *Snapshot

CollectOnce fans out over every source, builds one snapshot, publishes it, and returns it. A per-source failure degrades to license_up=0 (no seats emitted).

func (*Collector) Run

func (c *Collector) Run(ctx context.Context, interval time.Duration)

Run collects immediately, then every interval until ctx is canceled. It is a thin convenience over CollectOnce + RunTicker for callers that want the leading collect folded in.

func (*Collector) RunTicker

func (c *Collector) RunTicker(ctx context.Context, interval time.Duration)

RunTicker collects every interval until ctx is canceled. Unlike Run it does NOT collect immediately on entry — the caller is expected to have already run the first cycle (e.g. Server.RunCollection collects once to mark health ready, then hands off to RunTicker). This avoids a double back-to-back initial collect — two vCenter logins / two Graph fetches — on every startup and reload.

type Health

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

Health reports 503 "starting" until the first collection cycle completes, then 200 "ok" (design spec §2).

func (*Health) ServeHTTP

func (h *Health) ServeHTTP(w http.ResponseWriter, _ *http.Request)

func (*Health) SetReady

func (h *Health) SetReady()

type Label

type Label struct {
	Key   string
	Value string
}

Label is a single Prometheus label key/value pair.

type OTLPConfig

type OTLPConfig struct {
	Endpoint string `yaml:"endpoint"`
	Insecure bool   `yaml:"insecure"`
}

OTLPConfig configures the optional OTLP/gRPC push exporter. Endpoint empty disables OTLP entirely (the exporter then serves Prometheus only).

type PromCollector

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

PromCollector is an unchecked prometheus.Collector: Describe sends nothing, so the emitted metric-name/label set may vary snapshot to snapshot.

func NewPromCollector

func NewPromCollector(store *SnapshotStore) *PromCollector

func (*PromCollector) Collect

func (p *PromCollector) Collect(ch chan<- prometheus.Metric)

func (*PromCollector) Describe

func (p *PromCollector) Describe(chan<- *prometheus.Desc)

type Sample

type Sample struct {
	Name   string
	Labels []Label
	Value  float64
}

Sample is one vendor-agnostic metric point. Name is the full metric name (already prefixed with license_); Labels are in canonical (sorted-by-key) order for its metric name.

func BuildInfoSample

func BuildInfoSample(version, goVersion string) Sample

BuildInfoSample builds the constant license_build_info gauge (value 1).

func ExpirationSample

func ExpirationSample(vendor, product, instance string, tsUnix float64) Sample

ExpirationSample builds a license_expiration_timestamp_seconds sample ({instance,product,vendor}). Callers omit it entirely for perpetual licenses.

func LastSuccessSample

func LastSuccessSample(vendor, instance string, tsUnix float64) Sample

LastSuccessSample builds license_collector_last_success_timestamp_seconds.

func ScrapeDurationSample

func ScrapeDurationSample(vendor, instance string, seconds float64) Sample

ScrapeDurationSample builds license_scrape_duration_seconds.

func SeatSample

func SeatSample(name, vendor, product, unit, instance string, v float64) Sample

SeatSample builds a seats_total/seats_used sample with the canonical {instance,product,unit,vendor} label set (sorted by key).

func UpSample

func UpSample(vendor, instance string, up bool) Sample

UpSample builds license_up{vendor,instance}.

type Server

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

Server is the process-lifetime serving stack: one shared SnapshotStore, the Prometheus registry+collector, the OTLP push exporter, the /health handler, and a single bound HTTP server. It is built ONCE (NewServer) and reused across every reload — RunCollection swaps only the collection loop, never the store or the listener, so /metrics never blanks and /health never flips back to 503 on a reload (ADR-0008 §4).

func NewServer

func NewServer(base Base, version, addr string) (*Server, error)

NewServer builds+starts the process-lifetime serving stack (shared store, Prometheus registry+collector, OTLP exporter, /health, one bound listener). Bind failure is returned (fatal at startup only); a runtime serve error is LOGGED, never fatal.

func (*Server) ReloadLoop

func (s *Server) ReloadLoop(initialBase Base, initialSources []Source, reloads, shutdown <-chan struct{}, load func() (Base, []Source, error))

ReloadLoop is the reload state machine. It runs one collection loop under a cancelable context, then on each reload trigger validates a candidate (via load) and — only if it loads/validates — cancels the running loop and respawns collection with the new base/sources on the SAME server/store. A candidate that fails to load is logged and skipped: the running collection and server are left untouched. A shutdown trigger cancels the active loop and returns.

Callers adapt OS signals (SIGHUP → reloads, SIGINT/SIGTERM → shutdown) and fsnotify write/create events (→ reloads) into the two trigger channels; keeping them as plain channels makes this loop testable without real signals or files.

func (*Server) RunCollection

func (s *Server) RunCollection(ctx context.Context, sources []Source, interval time.Duration) error

RunCollection runs one collection into the SHARED store until ctx is canceled: build a collector from sources, CollectOnce, SetReady, then RunTicker(interval). Exactly one leading collect (no double initial collect). Publishing into the existing shared store means the prior snapshot keeps serving until the first CollectOnce swaps. Reloads call this repeatedly on the same *Server.

func (*Server) Shutdown

func (s *Server) Shutdown(ctx context.Context) error

Shutdown gracefully stops the HTTP server and the OTLP exporter.

type Snapshot

type Snapshot struct {
	Samples []Sample
}

Snapshot is an immutable set of samples produced by one collection cycle. Callers MUST NOT mutate Samples after publishing to a SnapshotStore.

func ColdStartSnapshot

func ColdStartSnapshot(version, goVersion string) *Snapshot

ColdStartSnapshot is served before the first collection cycle completes: it carries only license_build_info so no target series (license_up, seats_*) flap or read as a transient zero (design spec §2).

type SnapshotStore

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

SnapshotStore holds the current snapshot behind an RWMutex pointer-swap.

func NewSnapshotStore

func NewSnapshotStore(initial *Snapshot) *SnapshotStore

func (*SnapshotStore) Load

func (s *SnapshotStore) Load() *Snapshot

func (*SnapshotStore) Swap

func (s *SnapshotStore) Swap(next *Snapshot)

type Source

type Source interface {
	Vendor() string
	Instance() string
	Collect(ctx context.Context) ([]Sample, error)
}

Source collects license facts from a single configured target (one tenant or one vCenter). It returns samples already carrying vendor+instance labels; the collection loop stamps health/duration metrics around it.

Jump to

Keyboard shortcuts

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