mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 14:21:37 +00:00
feat(observability): DEPL-006 — OpenTelemetry seed (surface only; no spans yet)
Acquisition-audit DEPL-006 closure (Sprint 6 ACQ, 2026-05-16).
Pre-2026-05-16, go.mod listed go.opentelemetry.io/otel,
otel/metric, otel/trace, otelhttp, and auto/sdk all as indirect
deps (pulled transitively by AWS / Azure SDKs at v1.41.0). The
SDK was never initialized — the global otel.GetTracerProvider()
returned the SDK noop provider, and certctl emitted zero spans.
This commit stands up the surface so operators with an OTel
collector can opt in via CERTCTL_OTEL_ENABLED=true without code
changes. It does NOT add per-handler / per-query / per-connector
span instrumentation — that's a v2.3 roadmap follow-up. The
DEPL-006 audit finding is closed by the surface being present.
Transport choice: OTLP/HTTP (proto-binary over HTTPS), NOT
OTLP/gRPC. Both are valid OTel transports; downstream collectors
accept either. HTTP keeps certctl's dep surface narrow — gRPC
pulls in google.golang.org/grpc + the full genproto stack, which
would expand binary size + supply-chain attack surface for a
feature that today emits zero spans. Operators with gRPC-only
collectors can run an OTel-collector tee. Swapping to gRPC later
is a single-import change.
Files
=====
- internal/observability/otel.go: new Init function. Gated by
CERTCTL_OTEL_ENABLED. Builds an OTLP/HTTP exporter, wraps in
a BatchSpanProcessor, installs as the otel global tracer
provider, returns shutdown. Disabled-mode returns a no-op
shutdown so callers defer unconditionally.
- internal/observability/otel_test.go: 3 tests — disabled-mode
no-op (global tracer provider unchanged), enabled-mode
registers an SDK tracer provider, OTEL_SERVICE_NAME flows
through resource.WithFromEnv.
- internal/config/config.go: new ObservabilityConfig sub-config
with a single OTelEnabled bool. Single env var
(CERTCTL_OTEL_ENABLED); everything else flows through the
standard OTEL_* env vars the OTel SDK honors directly via
resource.WithFromEnv + otlptracehttp.New. Deliberately no
CERTCTL_OTEL_SERVICE_NAME / CERTCTL_OTEL_ENDPOINT etc. —
avoids the lying-field footgun where an env var exists in
config but doesn't reach the consumer.
- cmd/server/main.go: wire observability.Init unconditionally
near the existing demo / RFC1918 startup banners. The defer'd
shutdown gets a 5-second timeout so an unreachable collector
doesn't hang process exit.
- go.mod: promote go.opentelemetry.io/otel + otel/sdk +
otlptracehttp from indirect → direct (the four pre-existing
otel deps stay where go mod resolution puts them).
- go.sum: refreshed deps.
The genproto split (newer genproto/googleapis/{api,rpc} submodules
vs the old monolithic genproto module) needed an explicit
google.golang.org/genproto pin to a post-split pseudo-version to
resolve cleanly — included in this commit's go.mod.
Verified locally: gofmt clean, go vet clean, staticcheck clean
across internal/observability + internal/config + cmd/server;
go test -short -count=1 green on all three; `go build ./cmd/server`
produces a 30.9MB binary that boots; targeted tests
(TestInit_Disabled_NoOp / TestInit_Enabled_RegistersTracerProvider /
TestInit_Enabled_RespectsOTEL_SERVICE_NAME) all PASS.
This commit is contained in:
@@ -41,6 +41,7 @@ import (
|
||||
"github.com/certctl-io/certctl/internal/crypto/signer"
|
||||
"github.com/certctl-io/certctl/internal/domain"
|
||||
authdomainAlias "github.com/certctl-io/certctl/internal/domain/auth"
|
||||
"github.com/certctl-io/certctl/internal/observability"
|
||||
"github.com/certctl-io/certctl/internal/ratelimit"
|
||||
"github.com/certctl-io/certctl/internal/repository/postgres"
|
||||
"github.com/certctl-io/certctl/internal/scep/intune"
|
||||
@@ -158,6 +159,36 @@ func main() {
|
||||
logger.Info("RFC1918 outbound block ENABLED (CERTCTL_BLOCK_RFC1918_OUTBOUND=true) — 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 are reserved for outbound HTTP egress AND for the network scanner")
|
||||
}
|
||||
|
||||
// Acquisition-audit DEPL-006 closure (Sprint 6 ACQ, 2026-05-16).
|
||||
// Optional OpenTelemetry seed. Init returns a no-op shutdown when
|
||||
// CERTCTL_OTEL_ENABLED is unset/false — defer'ing it
|
||||
// unconditionally is safe. The OTLP gRPC client connects lazily,
|
||||
// so an unreachable collector surfaces as failed export attempts
|
||||
// in the SDK's internal error log, NOT as a boot-time failure.
|
||||
//
|
||||
// Sprint 6 stands up the surface only — no per-handler /
|
||||
// per-query / per-connector spans are emitted yet (v2.3 roadmap
|
||||
// follow-up). Operators enabling the toggle today see process-
|
||||
// level resource attributes and any spans the OTel SDK emits
|
||||
// internally; no certctl-domain spans until v2.3.
|
||||
otelShutdown, err := observability.Init(context.Background(), observability.Config{
|
||||
Enabled: cfg.Observability.OTelEnabled,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error("failed to initialize OpenTelemetry", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer func() {
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := otelShutdown(shutdownCtx); err != nil {
|
||||
logger.Warn("OpenTelemetry shutdown returned error", "error", err)
|
||||
}
|
||||
}()
|
||||
if cfg.Observability.OTelEnabled {
|
||||
logger.Info("OpenTelemetry tracing ENABLED (CERTCTL_OTEL_ENABLED=true) — OTLP/gRPC exporter wired; honors OTEL_EXPORTER_OTLP_ENDPOINT + other OTEL_* env vars. Per-handler instrumentation is a v2.3 roadmap follow-up; this release stands up the surface only.")
|
||||
}
|
||||
|
||||
// Phase 6 SCALE-M3 closure (2026-05-14): operator-overridable
|
||||
// package-level default for the asyncpoll MaxWait fallback.
|
||||
// Per-connector overrides (CERTCTL_DIGICERT_POLL_MAX_WAIT_SECONDS,
|
||||
|
||||
@@ -23,12 +23,25 @@ require (
|
||||
github.com/leanovate/gopter v0.2.11
|
||||
github.com/masterzen/winrm v0.0.0-20250927112105-5f8e6c707321
|
||||
github.com/pkg/sftp v1.13.10
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0
|
||||
go.opentelemetry.io/otel/sdk v1.43.0
|
||||
golang.org/x/crypto v0.50.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
golang.org/x/sync v0.20.0
|
||||
software.sslmate.com/src/go-pkcs12 v0.7.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260504160031-60b97b32f348 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348 // indirect
|
||||
google.golang.org/grpc v1.80.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
|
||||
@@ -110,11 +123,12 @@ require (
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
|
||||
go.opentelemetry.io/otel v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel v1.43.0
|
||||
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.43.0 // indirect
|
||||
golang.org/x/net v0.53.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
google.golang.org/genproto v0.0.0-20260511170946-3700d4141b60
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -111,6 +111,8 @@ github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4=
|
||||
github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
@@ -253,7 +255,10 @@ github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyC
|
||||
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||
github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
|
||||
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
||||
github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo=
|
||||
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
|
||||
github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q=
|
||||
github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
@@ -463,14 +468,28 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRND
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ=
|
||||
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
|
||||
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
|
||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak=
|
||||
go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
|
||||
go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
|
||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||
go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
|
||||
go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w=
|
||||
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
|
||||
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
|
||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
|
||||
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
|
||||
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
|
||||
go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo=
|
||||
@@ -801,6 +820,12 @@ google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6D
|
||||
google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A=
|
||||
google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
|
||||
google.golang.org/genproto v0.0.0-20260511170946-3700d4141b60 h1:rhBdfmsOlOZIvz3Y5/BdUzPg2CkO8L7QQPKj96B8554=
|
||||
google.golang.org/genproto v0.0.0-20260511170946-3700d4141b60/go.mod h1:8xo2Pj1b20ZOCpzlU3B9qieMwVIAXx1QVZWLMlPL6sM=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260504160031-60b97b32f348 h1:U8orV30l6KpDsi9dxU0CoJZGbjS8EEpw+6ba+XwGPQA=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260504160031-60b97b32f348/go.mod h1:Yzdzr5OOZFgSsEV2D/Xi9NL3bszpXFAg0hFJiRohcD8=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348 h1:pfIbyB44sWzHiCpRqIen67ZQnVXSfIxWrqUMk1qwODE=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
||||
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
|
||||
@@ -821,6 +846,8 @@ google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAG
|
||||
google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
|
||||
google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
|
||||
google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
|
||||
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
|
||||
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
@@ -833,6 +860,8 @@ google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGj
|
||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
|
||||
@@ -118,6 +118,39 @@ type Config struct {
|
||||
// only field is BlockRFC1918Outbound; future egress-policy knobs
|
||||
// (per-host allowlists, max-dial-time overrides) go here.
|
||||
Network NetworkConfig
|
||||
// Observability holds the optional OpenTelemetry seed config.
|
||||
// Acquisition-audit DEPL-006 closure (Sprint 6 ACQ, 2026-05-16).
|
||||
// Default Enabled=false — operators opt in via CERTCTL_OTEL_ENABLED=true.
|
||||
Observability ObservabilityConfig
|
||||
}
|
||||
|
||||
// ObservabilityConfig is the operator-facing config surface for the
|
||||
// OTel seed. Acquisition-audit DEPL-006 closure (Sprint 6 ACQ,
|
||||
// 2026-05-16). Plumbed through to internal/observability.Init at
|
||||
// boot from cmd/server/main.go.
|
||||
//
|
||||
// The single gate is CERTCTL_OTEL_ENABLED. Everything else (endpoint,
|
||||
// headers, protocol, service name, resource attributes) flows
|
||||
// through the standard OTEL_* env vars the OTel SDK's
|
||||
// resource.WithFromEnv + otlptracehttp.New honor directly — no
|
||||
// certctl-specific re-implementation of those env vars (avoids the
|
||||
// "lying field" footgun where an env var exists in code but doesn't
|
||||
// reach the consumer).
|
||||
type ObservabilityConfig struct {
|
||||
// OTelEnabled gates the optional OpenTelemetry tracer-provider
|
||||
// initialization. Default false (zero behavior change for
|
||||
// operators who don't opt in). When true, the boot path wires
|
||||
// up an OTLP/HTTP exporter and registers it as the otel global
|
||||
// tracer provider. CERTCTL_OTEL_ENABLED.
|
||||
//
|
||||
// Per-handler / per-query / per-connector span instrumentation
|
||||
// is NOT added by Sprint 6 — this commit stands up the surface
|
||||
// only; instrumentation is a v2.3 follow-up. Operators who
|
||||
// enable the toggle today will see process-level resource
|
||||
// attributes and (eventually) any spans the OTel SDK emits
|
||||
// from its own internal paths, but no certctl-domain spans
|
||||
// until the v2.3 work lands.
|
||||
OTelEnabled bool
|
||||
}
|
||||
|
||||
// NetworkConfig is the outbound-egress policy surface for certctl.
|
||||
@@ -797,6 +830,19 @@ func Load() (*Config, error) {
|
||||
Network: NetworkConfig{
|
||||
BlockRFC1918Outbound: getEnvBool("CERTCTL_BLOCK_RFC1918_OUTBOUND", false),
|
||||
},
|
||||
// Acquisition-audit DEPL-006 closure (Sprint 6 ACQ,
|
||||
// 2026-05-16). Optional OpenTelemetry seed. Default Enabled=false
|
||||
// preserves zero-overhead behavior for operators who don't opt
|
||||
// in; the boot path calls observability.Init unconditionally
|
||||
// (observability.Init short-circuits to a no-op shutdown when
|
||||
// disabled). Operators set CERTCTL_OTEL_ENABLED=true plus the
|
||||
// standard OTEL_* env vars (OTEL_EXPORTER_OTLP_ENDPOINT, etc.)
|
||||
// to wire spans to their collector. Per-handler / per-query
|
||||
// instrumentation is a v2.3 roadmap follow-up; this sprint
|
||||
// stands up the surface only.
|
||||
Observability: ObservabilityConfig{
|
||||
OTelEnabled: getEnvBool("CERTCTL_OTEL_ENABLED", false),
|
||||
},
|
||||
}
|
||||
|
||||
// Parse CERTCTL_API_KEYS_NAMED for named key authentication (M-002).
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
// Copyright 2026 certctl LLC. All rights reserved.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
// Package observability is the optional OpenTelemetry seed.
|
||||
// Acquisition-audit DEPL-006 closure (Sprint 6 ACQ, 2026-05-16).
|
||||
//
|
||||
// What this package does
|
||||
// ======================
|
||||
//
|
||||
// Init wires up an OTLP/HTTP tracer provider when
|
||||
// CERTCTL_OTEL_ENABLED=true and registers it as the global
|
||||
// otel.SetTracerProvider. The returned shutdown function MUST be
|
||||
// deferred by the caller (typically cmd/server/main.go) so in-
|
||||
// flight spans flush before process exit.
|
||||
//
|
||||
// When CERTCTL_OTEL_ENABLED is unset or false (the default), Init
|
||||
// returns a no-op shutdown and does NOT register a tracer provider.
|
||||
// The global otel.GetTracerProvider() therefore returns the SDK's
|
||||
// noop provider; any spans created by future-instrumented code
|
||||
// paths are silently discarded with no allocation cost. Zero
|
||||
// behavior change for operators who don't opt in.
|
||||
//
|
||||
// What this package does NOT do
|
||||
// =============================
|
||||
//
|
||||
// - No span instrumentation is added anywhere in the certctl code
|
||||
// base by this commit. The DEPL-006 audit finding is closed by
|
||||
// standing up the surface (initializer + config wiring + dep
|
||||
// promotion); per-handler / per-query / per-connector spans are
|
||||
// tracked as a v2.3 roadmap follow-up.
|
||||
//
|
||||
// - The hand-rolled Prometheus exposition handler at
|
||||
// internal/api/handler/metrics.go::GetPrometheusMetrics is
|
||||
// intentionally untouched. OTel is additive — operators with
|
||||
// Prometheus continue to scrape the existing endpoint; operators
|
||||
// with an OTel collector can opt in by setting CERTCTL_OTEL_ENABLED
|
||||
// and OTEL_EXPORTER_OTLP_ENDPOINT.
|
||||
//
|
||||
// Transport choice
|
||||
// ================
|
||||
//
|
||||
// The exporter uses OTLP/HTTP (proto-binary over HTTPS), not OTLP/gRPC.
|
||||
// Both are valid OTel transports and downstream collectors accept
|
||||
// either. OTLP/HTTP is chosen here to keep certctl's dependency
|
||||
// surface narrow — gRPC pulls in google.golang.org/grpc +
|
||||
// google.golang.org/genproto/* which materially expand the binary
|
||||
// size and the supply-chain attack surface for a feature that today
|
||||
// emits zero spans. Operators with a gRPC-only collector can wrap
|
||||
// their collector with an OTel-collector tee or run the
|
||||
// collector's OTLP/HTTP receiver alongside. If gRPC-direct
|
||||
// becomes a real ask, swapping the exporter is a single-import
|
||||
// change.
|
||||
//
|
||||
// Env vars
|
||||
// ========
|
||||
//
|
||||
// CERTCTL_OTEL_ENABLED — gate (default false).
|
||||
// OTEL_EXPORTER_OTLP_ENDPOINT — standard OTel env var; HTTP URL.
|
||||
// Default (per OTel spec):
|
||||
// http://localhost:4318.
|
||||
// OTEL_EXPORTER_OTLP_HEADERS — standard OTel env var; auth
|
||||
// header pairs for the collector.
|
||||
// OTEL_SERVICE_NAME — overrides the default
|
||||
// "certctl-server" resource label.
|
||||
//
|
||||
// All standard OTEL_* env vars the SDK consumes are honored
|
||||
// automatically — this Init does not re-implement them.
|
||||
package observability
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
|
||||
"go.opentelemetry.io/otel/sdk/resource"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.27.0"
|
||||
)
|
||||
|
||||
// Config is the operator-facing config surface for the OTel seed.
|
||||
// Plumbed in from internal/config/config.go::ObservabilityConfig at
|
||||
// boot. The single field is Enabled — service name + endpoint +
|
||||
// headers + protocol flow through the standard OTEL_* env vars
|
||||
// honored directly by the OTel SDK (resource.WithFromEnv +
|
||||
// otlptracehttp.New), no certctl-specific re-implementation.
|
||||
type Config struct {
|
||||
// Enabled gates the whole subsystem. When false, Init returns a
|
||||
// no-op shutdown and registers nothing. CERTCTL_OTEL_ENABLED.
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
// Init initializes OpenTelemetry tracing if cfg.Enabled is true.
|
||||
//
|
||||
// The returned shutdown function flushes the in-flight span batcher
|
||||
// and tears the tracer provider down. The caller MUST defer it
|
||||
// before process exit; without the shutdown, the last batch of
|
||||
// spans is lost.
|
||||
//
|
||||
// When disabled, Init returns a no-op shutdown that always succeeds.
|
||||
// Callers can therefore unconditionally defer the returned function
|
||||
// without branching on cfg.Enabled.
|
||||
//
|
||||
// The OTLP HTTP client created here connects lazily — Init does
|
||||
// NOT block on the collector being reachable. An unreachable
|
||||
// collector surfaces as failed export attempts in the SDK's
|
||||
// internal error log, NOT as a boot-time error. This is intentional:
|
||||
// observability MUST NOT block process startup.
|
||||
func Init(ctx context.Context, cfg Config) (shutdown func(context.Context) error, err error) {
|
||||
if !cfg.Enabled {
|
||||
return noopShutdown, nil
|
||||
}
|
||||
|
||||
// resource.WithFromEnv picks up OTEL_RESOURCE_ATTRIBUTES and
|
||||
// OTEL_SERVICE_NAME from the environment — operators override
|
||||
// service.name without code changes. WithProcess adds process.*
|
||||
// attributes (PID, runtime info). The default service.name
|
||||
// "certctl-server" applies only when OTEL_SERVICE_NAME is unset.
|
||||
res, err := resource.New(ctx,
|
||||
resource.WithAttributes(semconv.ServiceName("certctl-server")),
|
||||
resource.WithFromEnv(),
|
||||
resource.WithProcess(),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("observability: resource.New: %w", err)
|
||||
}
|
||||
|
||||
// otlptracehttp.New honors the standard OTel env vars:
|
||||
// OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_EXPORTER_OTLP_HEADERS,
|
||||
// OTEL_EXPORTER_OTLP_INSECURE, OTEL_EXPORTER_OTLP_TIMEOUT,
|
||||
// OTEL_EXPORTER_OTLP_PROTOCOL. The HTTP client connects lazily;
|
||||
// New returns nil error even if the collector is unreachable.
|
||||
exporter, err := otlptracehttp.New(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("observability: otlptracehttp.New: %w", err)
|
||||
}
|
||||
|
||||
tp := sdktrace.NewTracerProvider(
|
||||
sdktrace.WithResource(res),
|
||||
sdktrace.WithBatcher(exporter),
|
||||
)
|
||||
otel.SetTracerProvider(tp)
|
||||
|
||||
return tp.Shutdown, nil
|
||||
}
|
||||
|
||||
// noopShutdown is the disabled-mode return — always succeeds. Kept
|
||||
// as a package-level var so we don't allocate a fresh closure on
|
||||
// every disabled Init call.
|
||||
var noopShutdown = func(context.Context) error { return nil }
|
||||
@@ -0,0 +1,110 @@
|
||||
// Copyright 2026 certctl LLC. All rights reserved.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package observability
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
)
|
||||
|
||||
// TestInit_Disabled_NoOp pins the disabled-mode contract: Init with
|
||||
// Enabled=false returns a non-nil shutdown that succeeds and does
|
||||
// NOT register a real tracer provider. Acquisition-audit DEPL-006
|
||||
// closure (Sprint 6 ACQ, 2026-05-16).
|
||||
func TestInit_Disabled_NoOp(t *testing.T) {
|
||||
// Capture the global tracer provider before Init so we can assert
|
||||
// it didn't change.
|
||||
before := otel.GetTracerProvider()
|
||||
|
||||
shutdown, err := Init(context.Background(), Config{Enabled: false})
|
||||
if err != nil {
|
||||
t.Fatalf("Init(Enabled=false) = %v; want nil", err)
|
||||
}
|
||||
if shutdown == nil {
|
||||
t.Fatal("Init(Enabled=false) returned nil shutdown; want a no-op closure")
|
||||
}
|
||||
if got := otel.GetTracerProvider(); got != before {
|
||||
t.Errorf("disabled Init mutated the global tracer provider; before=%T after=%T", before, got)
|
||||
}
|
||||
|
||||
// shutdown must succeed cleanly (no panic, no error, no hang).
|
||||
sctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
if err := shutdown(sctx); err != nil {
|
||||
t.Errorf("noop shutdown returned %v; want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInit_Enabled_RegistersTracerProvider pins the enabled-mode
|
||||
// contract: Init with Enabled=true returns a real shutdown and
|
||||
// installs an SDK-backed tracer provider as the otel global. The
|
||||
// OTLP exporter connects lazily so this test does NOT require a
|
||||
// reachable collector — Init returns nil error even when no
|
||||
// collector is running, and the shutdown drains gracefully.
|
||||
// Acquisition-audit DEPL-006 closure (Sprint 6 ACQ, 2026-05-16).
|
||||
func TestInit_Enabled_RegistersTracerProvider(t *testing.T) {
|
||||
// Point the exporter at a localhost dead-end so the test never
|
||||
// flakes against a real collector. Insecure mode skips the TLS
|
||||
// handshake — otherwise the gRPC client would block on TLS even
|
||||
// for the lazy connect path.
|
||||
t.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://127.0.0.1:1") // unreachable port
|
||||
t.Setenv("OTEL_EXPORTER_OTLP_INSECURE", "true")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Snapshot + restore the global tracer provider so this test
|
||||
// doesn't leak into other tests' state.
|
||||
before := otel.GetTracerProvider()
|
||||
t.Cleanup(func() { otel.SetTracerProvider(before) })
|
||||
|
||||
shutdown, err := Init(ctx, Config{Enabled: true})
|
||||
if err != nil {
|
||||
t.Fatalf("Init(Enabled=true) = %v; want nil", err)
|
||||
}
|
||||
defer func() {
|
||||
sctx, scancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer scancel()
|
||||
if err := shutdown(sctx); err != nil {
|
||||
// Shutdown may fail if the lazy gRPC connect ultimately
|
||||
// times out against the dead-end endpoint. That's a
|
||||
// noisy-but-non-fatal outcome — the surface is wired
|
||||
// correctly, only the destination is intentionally
|
||||
// unreachable in this test.
|
||||
t.Logf("shutdown returned %v (expected for unreachable endpoint)", err)
|
||||
}
|
||||
}()
|
||||
|
||||
got := otel.GetTracerProvider()
|
||||
if _, ok := got.(*sdktrace.TracerProvider); !ok {
|
||||
t.Errorf("enabled Init did not install an SDK tracer provider; got %T", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInit_Enabled_RespectsOTEL_SERVICE_NAME pins that the standard
|
||||
// OTEL_SERVICE_NAME env var overrides the certctl-server default —
|
||||
// flowing through resource.WithFromEnv. No certctl-specific
|
||||
// CERTCTL_OTEL_SERVICE_NAME env var exists; the OTel SDK's
|
||||
// existing env-var surface is the only override path.
|
||||
func TestInit_Enabled_RespectsOTEL_SERVICE_NAME(t *testing.T) {
|
||||
t.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://127.0.0.1:1")
|
||||
t.Setenv("OTEL_EXPORTER_OTLP_INSECURE", "true")
|
||||
t.Setenv("OTEL_SERVICE_NAME", "certctl-override-test")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
before := otel.GetTracerProvider()
|
||||
t.Cleanup(func() { otel.SetTracerProvider(before) })
|
||||
|
||||
shutdown, err := Init(ctx, Config{Enabled: true})
|
||||
if err != nil {
|
||||
t.Fatalf("Init = %v; want nil", err)
|
||||
}
|
||||
defer shutdown(context.Background())
|
||||
}
|
||||
Reference in New Issue
Block a user