Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
56 changes: 54 additions & 2 deletions dials.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"io"
"reflect"
"slices"

"github.com/vimeo/dials/ptrify"
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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})

Expand All @@ -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
}

Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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:
Expand All @@ -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:
Expand Down
32 changes: 0 additions & 32 deletions dials_118.go

This file was deleted.

149 changes: 148 additions & 1 deletion dials_119.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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}
}
15 changes: 7 additions & 8 deletions go.mod
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
)

Expand All @@ -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
)
Loading
Loading