diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index dc182b6..a53e9d1 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -7,7 +7,7 @@ jobs: strategy: matrix: os: [macos-latest, ubuntu-latest] - goversion: ["1.23", "1.24", "1.25"] + goversion: ["1.24", "1.25"] steps: - name: Set up Go ${{matrix.goversion}} on ${{matrix.os}} uses: actions/setup-go@v6 diff --git a/dials.go b/dials.go index 477386d..fabc8e0 100644 --- a/dials.go +++ b/dials.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "reflect" + "slices" "github.com/vimeo/dials/ptrify" ) @@ -70,6 +71,16 @@ type Params[T any] struct { // - DelayInitialVerification was set to true when Config was called // - EnableVerification has not been called (without it returning an error) CallGlobalCallbacksAfterVerificationEnabled bool + + // If enabled, Dials will retain the slice of reported source-values so + // it can render a status page using [github.com/vimeo/go-status-page] + // + // This feature is experimental and go-status-page may generate panics + // while rendering pages. + // + // It is heavily recommended that users of this functionality set + // `statuspage:"-"` tags on any sensitive fields with secrets/credentials, etc. + EnableStatusPage bool } // Config populates the passed in config struct by reading the values from the @@ -138,9 +149,11 @@ func (p Params[T]) Config(ctx context.Context, t *T, sources ...Source) (*Dials[ nv, _ := newValue.(*T) + dumpStackCh := make(chan dumpSourceStack[T]) d := &Dials[T]{ updatesChan: make(chan *T, 1), params: p, + dumpStack: dumpStackCh, } d.value.Store(&versionedConfig[T]{serial: 0, cfg: nv}) @@ -166,8 +179,15 @@ func (p Params[T]) Config(ctx context.Context, t *T, sources ...Source) (*Dials[ monCtl := make(chan verifyEnable[T], 3) d.monCtl = monCtl - go d.monitor(ctx, tVal.Interface().(*T), computed, watcherChan, monCtl) + go d.monitor(ctx, tVal.Interface().(*T), computed, watcherChan, monCtl, dumpStackCh) } + if p.EnableStatusPage { + if !someoneWatching { + d.sourceVals.Store(&computed) + } + d.defVal = tVal.Interface().(*T) + } + return d, nil } @@ -211,6 +231,15 @@ type valueUpdate struct { func (valueUpdate) isStatusReport() {} +type dumpSourceStackResponse[T any] struct { + stack []sourceValue + serial CfgSerial[T] +} + +type dumpSourceStack[T any] struct { + resp chan<- dumpSourceStackResponse[T] +} + type watcherDone struct { source Source } @@ -355,13 +384,20 @@ type CfgSerial[T any] struct { // Events returns a channel that will get a message every time the configuration // is updated. +// +// NOTE: In general, it is preferable to register a callback with +// [Dials.RegisterCallback], due to a cleaner interface and the ability to +// register multiple callbacks. Additionally, [NewConfigHandler] +// implementations get both the old and new configs, reducing the amount of +// state required to handle new events. func (d *Dials[T]) Events() <-chan *T { return d.updatesChan } // Fill populates the passed struct with the current value of the configuration. // It is a thin wrapper around assignment -// deprecated: assign return value from View() instead +// +// Deprecated: assign return value from View() instead. (this is a legacy method that predates generics) func (d *Dials[T]) Fill(blankConfig *T) { *blankConfig = *d.View() } @@ -634,12 +670,14 @@ func (d *Dials[T]) monitor( sourceValues []sourceValue, watcherChan chan watchStatusUpdate, monCtl <-chan verifyEnable[T], + dumpStack <-chan dumpSourceStack[T], ) { defer close(d.cbch) skipVerify := d.params.DelayInitialVerification for { select { case <-ctx.Done(): + d.sourceVals.Store(&sourceValues) return case v := <-monCtl: if !skipVerify { @@ -654,6 +692,19 @@ func (d *Dials[T]) monitor( continue } skipVerify = !d.monitorEnableVerify(v) + case v := <-dumpStack: + // fetch the serial from inside the monitor because it + // guarantees that we render a consistent view. + _, serial := d.ViewVersion() + select { + case v.resp <- dumpSourceStackResponse[T]{ + stack: slices.Clone(sourceValues), + serial: serial, + }: + close(v.resp) + default: + // besteffort response + } case watchTab := <-watcherChan: switch v := watchTab.(type) { case *valueUpdate: @@ -680,6 +731,7 @@ func (d *Dials[T]) monitor( case *watcherDone: if !d.markSourceDone(ctx, sourceValues, v) { // if there are no watching sources, just exit. + d.sourceVals.Store(&sourceValues) return } default: diff --git a/dials_118.go b/dials_118.go deleted file mode 100644 index 0f1ffb1..0000000 --- a/dials_118.go +++ /dev/null @@ -1,32 +0,0 @@ -//go:build !go1.19 - -package dials - -import ( - "sync/atomic" -) - -// Dials is the main access point for your configuration. -type Dials[T any] struct { - value atomic.Value - updatesChan chan *T - params Params[T] - cbch chan<- userCallbackEvent - monCtl chan<- verifyEnable[T] -} - -// View returns the configuration struct populated. -func (d *Dials[T]) View() *T { - v, _ := d.value.Load().(*versionedConfig[T]) - // v cannot be nil because we initialize this value immediately after - // creating the the Dials object - return v.cfg -} - -// View returns the configuration struct populated, and an opaque token. -func (d *Dials[T]) ViewVersion() (*T, CfgSerial[T]) { - v, _ := d.value.Load().(*versionedConfig[T]) - // v cannot be nil because we initialize this value immediately after - // creating the the Dials object - return v.cfg, CfgSerial[T]{s: v.serial, cfg: v.cfg} -} diff --git a/dials_119.go b/dials_119.go index cee03e7..0d80af7 100644 --- a/dials_119.go +++ b/dials_119.go @@ -3,7 +3,14 @@ package dials import ( + "fmt" + "net/http" "sync/atomic" + + "golang.org/x/net/html" + "golang.org/x/net/html/atom" + + statuspage "github.com/vimeo/go-status-page" ) // Dials is the main access point for your configuration. @@ -13,6 +20,12 @@ type Dials[T any] struct { params Params[T] cbch chan<- userCallbackEvent monCtl chan<- verifyEnable[T] + dumpStack chan<- dumpSourceStack[T] + + // sourceVals and defVal are only present if the status page is enabled + // and there are no watching sources. (or the monitor has exited) + sourceVals atomic.Pointer[[]sourceValue] + defVal *T } // View returns the configuration struct populated. @@ -23,11 +36,145 @@ func (d *Dials[T]) View() *T { return versioned.cfg } -// View returns the configuration struct populated, and an opaque token. +// ViewVersion returns the configuration struct populated, and an opaque token. func (d *Dials[T]) ViewVersion() (*T, CfgSerial[T]) { versioned := d.value.Load() // v cannot be nil because we initialize this value immediately after // creating the the Dials object return versioned.cfg, CfgSerial[T]{s: versioned.serial, cfg: versioned.cfg} +} + +// ServeHTTP is only active if [Params.EnableStatusPage] was set to true when creating the dials instance. +// +// This is experimental and may panic while serving, buyer beware!!! +// +// It is heavily recommended that users of this functionality set +// `statuspage:"-"` tags on any sensitive fields with secrets/credentials, etc. +func (d *Dials[T]) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if !d.params.EnableStatusPage { + http.Error(w, "Dials status page not enabled. EnableStatusPage must be set in dials.Params.", http.StatusNotFound) + return + } + srcs := d.sourceVals.Load() + _, serial := d.ViewVersion() + if srcs == nil { + // Make the response channel size-1 so the monitor doesn't block on the response + respCh := make(chan dumpSourceStackResponse[T], 1) + // ask the monitor for the stack + select { + case d.dumpStack <- dumpSourceStack[T]{resp: respCh}: + case <-r.Context().Done(): + // not worth trying to resolve the race around when the monitor shuts down for a status page. + // just return a 500. + http.Error(w, "context expired while attempting to request the current source-stack; please try again.", + http.StatusInternalServerError) + return + } + select { + case v := <-respCh: + srcs = &v.stack + serial = v.serial + case <-r.Context().Done(): + // not worth trying to resolve the race around when the monitor shuts down for a status page. + // just return a 500. + http.Error(w, "context expired while attempting to acquire the current stacks; please try again.", + http.StatusInternalServerError) + return + } + } + + root := html.Node{Type: html.DocumentNode} + root.AppendChild(&html.Node{ + Type: html.DoctypeNode, + DataAtom: atom.Html, + Data: atom.Html.String(), + }) + htmlElem := createElemAtom(atom.Html) + root.AppendChild(htmlElem) + + head := createElemAtom(atom.Head) + + htmlElem.AppendChild(head) + title := createElemAtom(atom.Title) + title.AppendChild(textNode("Dials Status")) + head.AppendChild(title) + header := createElemAtom(atom.H1) + header.AppendChild(textNode("Dials Status")) + head.AppendChild(header) + + body := createElemAtom(atom.Body) + htmlElem.AppendChild(body) + + curCfg, genCfgErr := statuspage.GenHTMLNodes(serial.cfg) + if genCfgErr != nil { + http.Error(w, fmt.Sprintf("failed to render status page for current config of type %T: %s.", serial.cfg, genCfgErr), + http.StatusInternalServerError) + return + } + curStatusH2 := createElemAtom(atom.H2) + curStatusH2.AppendChild(textNode("current configuration")) + body.AppendChild(curStatusH2) + curStatusVers := createElemAtom(atom.P) + curStatusVers.AppendChild(textNode(fmt.Sprintf("Current Serial: %d", serial.s))) + body.AppendChild(curStatusVers) + + for _, cfgNode := range curCfg { + // add a horizontal rule to separate sections + body.AppendChild(createElemAtom(atom.Hr)) + body.AppendChild(cfgNode) + } + defCfgH2 := createElemAtom(atom.H2) + defCfgH2.AppendChild(textNode("Default Configuration")) + body.AppendChild(defCfgH2) + defCfgNodes, defCfgErr := statuspage.GenHTMLNodes(d.defVal) + if defCfgErr != nil { + http.Error(w, fmt.Sprintf("failed to render status page for default config of type %T: %s.", + serial.cfg, defCfgErr), + http.StatusInternalServerError) + return + } + + for _, cfgNode := range defCfgNodes { + // add a horizontal rule to separate sections + body.AppendChild(createElemAtom(atom.Hr)) + body.AppendChild(cfgNode) + } + + for srcIdx, srcVal := range *srcs { + body.AppendChild(createElemAtom(atom.Hr)) + srcSectionHeader := createElemAtom(atom.H2) + srcSectionHeader.AppendChild(textNode(fmt.Sprintf("Source %d of type %T (watching %t)", srcIdx, srcVal.source, srcVal.watching))) + body.AppendChild(srcSectionHeader) + srcBodyNodes, srcBodyGenErr := statuspage.GenHTMLNodes(srcVal.value.Interface()) + if srcBodyGenErr != nil { + http.Error(w, fmt.Sprintf("failed to render status page for config of type %T on source %d: %s.", serial.cfg, srcIdx, srcBodyGenErr), + http.StatusInternalServerError) + return + } + for _, bn := range srcBodyNodes { + // add a horizontal rule to separate sections + body.AppendChild(createElemAtom(atom.Hr)) + body.AppendChild(bn) + } + } + + if renderErr := html.Render(w, htmlElem); renderErr != nil { + http.Error(w, fmt.Sprintf("failed to render status page into html for config of type %T : %s.", serial.cfg, renderErr), + http.StatusInternalServerError) + } +} + +func createElemAtom(d atom.Atom) *html.Node { + n := &html.Node{Type: html.ElementNode, DataAtom: d, Data: d.String()} + if n.DataAtom == atom.Table { + n.Attr = append(n.Attr, html.Attribute{ + Key: "style", + Val: "border: 1px solid; min-width: 100px", + }) + } + return n +} +func textNode(d string) *html.Node { + return &html.Node{Type: html.TextNode, Data: d} } diff --git a/go.mod b/go.mod index 8f07725..07be2a9 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,6 @@ module github.com/vimeo/dials -go 1.23.0 - -toolchain go1.24.4 +go 1.24.0 require ( cuelang.org/go v0.13.0 @@ -11,7 +9,9 @@ require ( github.com/pelletier/go-toml v1.9.5 github.com/spf13/pflag v1.0.6 github.com/stretchr/testify v1.9.0 - golang.org/x/text v0.24.0 + github.com/vimeo/go-status-page v0.0.0-20251112170003-7780d6931432 + golang.org/x/net v0.46.0 + golang.org/x/text v0.30.0 gopkg.in/yaml.v2 v2.4.0 ) @@ -28,10 +28,9 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/protocolbuffers/txtpbfmt v0.0.0-20250129171521-feedd8250727 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect - golang.org/x/mod v0.24.0 // indirect - golang.org/x/net v0.39.0 // indirect + golang.org/x/mod v0.28.0 // indirect golang.org/x/oauth2 v0.29.0 // indirect - golang.org/x/sync v0.13.0 // indirect - golang.org/x/sys v0.32.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.37.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index d80486c..f2a16c9 100644 --- a/go.sum +++ b/go.sum @@ -46,20 +46,22 @@ github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= -golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= -golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= -golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= +github.com/vimeo/go-status-page v0.0.0-20251112170003-7780d6931432 h1:UpLaSLJYRDYN2wmHEOBVg1H6N758LjhjasNJG3AnfgI= +github.com/vimeo/go-status-page v0.0.0-20251112170003-7780d6931432/go.mod h1:ncGOyV9g3fPsmq4zenWwLVZtYW+lTWylUNHNEJK0byQ= +golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U= +golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= +golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98= golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= -golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= -golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= -golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= -golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= -golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU= -golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= +golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=