diff --git a/dependency/consul_namespaces.go b/dependency/consul_namespaces.go new file mode 100644 index 000000000..0e9ba8495 --- /dev/null +++ b/dependency/consul_namespaces.go @@ -0,0 +1,105 @@ +package dependency + +import ( + "fmt" + "github.com/hashicorp/consul/api" + "log" + "net/url" + "slices" + "strings" + "time" +) + +// Ensure implements +var ( + _ Dependency = (*ListNamespacesQuery)(nil) + + // ListNamespacesQuerySleepTime is the amount of time to sleep between + // queries, since the endpoint does not support blocking queries. + ListNamespacesQuerySleepTime = DefaultNonBlockingQuerySleepTime +) + +type Namespace struct { + Name string + Description string +} + +// ListNamespacesQuery is the representation of a requested namespaces +// dependency from inside a template. +type ListNamespacesQuery struct { + stopCh chan struct{} +} + +func (c *ListNamespacesQuery) Fetch(clients *ClientSet, opts *QueryOptions) (interface{}, *ResponseMetadata, error) { + opts = opts.Merge(&QueryOptions{}) + + log.Printf("[TRACE] %s: GET %s", c, &url.URL{ + Path: "/v1/namespaces", + RawQuery: opts.String(), + }) + + // This is certainly not elegant, but the namespaces endpoint does not support + // blocking queries, so we are going to "fake it until we make it". When we + // first query, the LastIndex will be "0", meaning we should immediately + // return data, but future calls will include a LastIndex. If we have a + // LastIndex in the query metadata, sleep for 15 seconds before asking Consul + // again. + // + // This is probably okay given the frequency in which namespaces actually + // change, but is technically not edge-triggering. + if opts.WaitIndex != 0 { + log.Printf("[TRACE] %s: long polling for %s", c, ListNamespacesQuerySleepTime) + + select { + case <-c.stopCh: + return nil, nil, ErrStopped + case <-time.After(ListNamespacesQuerySleepTime): + } + } + + namespaces, _, err := clients.Consul().Namespaces().List(opts.ToConsulOpts()) + if err != nil { + if strings.Contains(err.Error(), "Invalid URL path") { + return nil, nil, fmt.Errorf("%s: Namespaces are an enterprise feature: %w", c.String(), err) + } + + return nil, nil, fmt.Errorf("%s: %w", c.String(), err) + } + + log.Printf("[TRACE] %s: returned %d results", c, len(namespaces)) + + slices.SortFunc(namespaces, func(i, j *api.Namespace) int { + return strings.Compare(i.Name, j.Name) + }) + + resp := []*Namespace{} + for _, namespace := range namespaces { + if namespace != nil { + resp = append(resp, &Namespace{ + Name: namespace.Name, + Description: namespace.Description, + }) + } + } + + // Use respWithMetadata which always increments LastIndex and results + // in fetching new data for endpoints that don't support blocking queries + return respWithMetadata(resp) +} + +// CanShare returns if this dependency is shareable when consul-template is running in de-duplication mode. +func (c *ListNamespacesQuery) CanShare() bool { + return true +} + +func (c *ListNamespacesQuery) String() string { + return "list.namespaces" +} + +func (c *ListNamespacesQuery) Stop() { + close(c.stopCh) +} + +func (c *ListNamespacesQuery) Type() Type { + return TypeConsul +} diff --git a/docs/templating-language.md b/docs/templating-language.md index 387bcb976..15761782c 100644 --- a/docs/templating-language.md +++ b/docs/templating-language.md @@ -20,6 +20,7 @@ provides the following functions: * [`safeLs`](#safels) * [`node`](#node) * [`nodes`](#nodes) + * [`namespaces`](#namespaces) * [`partitions`](#partitions) * [`peerings`](#peerings) * [`secret`](#secret) @@ -526,6 +527,18 @@ To query a different data center and order by shortest trip time to ourselves: To access map data such as `TaggedAddresses` or `Meta`, use [Go's text/template][text-template] map indexing. +### `namespaces` + +Query [Consul][consul] for all namespaces in the Consul cluster. + +**Note:** Namespaces are an Enterprise-only feature of Consul. + +```golang +{{ range namespaces }} +{{ .Name }} : {{ .Description }} +{{ end }} +``` + ### `partitions` Query [Consul][consul] for all partitions. diff --git a/template/funcs.go b/template/funcs.go index 181e12a3e..dd0acd875 100644 --- a/template/funcs.go +++ b/template/funcs.go @@ -97,6 +97,28 @@ func partitionsFunc(b *Brain, used, missing *dep.Set) func() ([]*dep.Partition, } } +// namespacesFunc returns or accumulates namespace dependencies. +func namespacesFunc(b *Brain, used, missing *dep.Set) func() ([]*dep.Namespace, error) { + return func() ([]*dep.Namespace, error) { + result := []*dep.Namespace{} + + d, err := dep.NewListNamespacesQuery() + if err != nil { + return result, err + } + + used.Add(d) + + if value, ok := b.Recall(d); ok { + return value.([]*dep.Namespace), nil + } + + missing.Add(d) + + return result, nil + } +} + // exportedServicesFunc returns or accumulates partition dependencies. func exportedServicesFunc(b *Brain, used, missing *dep.Set) func(...string) ([]dep.ExportedService, error) { return func(s ...string) ([]dep.ExportedService, error) { diff --git a/template/template.go b/template/template.go index 48484b3b8..ae6d271da 100644 --- a/template/template.go +++ b/template/template.go @@ -354,6 +354,7 @@ func funcMap(i *funcMapInput) template.FuncMap { "node": nodeFunc(i.brain, i.used, i.missing), "nodes": nodesFunc(i.brain, i.used, i.missing), "partitions": partitionsFunc(i.brain, i.used, i.missing), + "namespaces": namespacesFunc(i.brain, i.used, i.missing), "peerings": peeringsFunc(i.brain, i.used, i.missing), "secret": secretFunc(i.brain, i.used, i.missing), "secrets": secretsFunc(i.brain, i.used, i.missing),