From a7eb24d3dd401dd22313845f0ba13318dbd805a6 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Thu, 20 Aug 2026 17:30:42 +0300 Subject: [PATCH 1/9] fix(playground): prevent eval-server port conflicts Select an available loopback port when the preferred evaluation-server port is occupied, while keeping Vite's proxy aligned with the selected port. Force dependency re-optimization when serving from the sibling clicky-ui source to prevent stale package paths. --- web/apps/playground/plugins/eval-server.ts | 44 +++++++++++++- web/apps/playground/test/viteConfig.test.ts | 64 +++++++++++++++++++++ web/apps/playground/vite.config.ts | 38 ++++++++++-- 3 files changed, 138 insertions(+), 8 deletions(-) create mode 100644 web/apps/playground/test/viteConfig.test.ts diff --git a/web/apps/playground/plugins/eval-server.ts b/web/apps/playground/plugins/eval-server.ts index cce616911..d350ff70a 100644 --- a/web/apps/playground/plugins/eval-server.ts +++ b/web/apps/playground/plugins/eval-server.ts @@ -1,13 +1,53 @@ import { spawn, type ChildProcess } from "node:child_process"; +import { createServer } from "node:net"; import type { Plugin } from "vite"; export interface EvalServerOptions { /** Repository root, where `go run` is invoked. */ repoRoot: string; + /** Loopback host the Go server listens on. */ + host: string; /** Port the Go server listens on. */ port: number; } +interface AvailablePortOptions { + host: string; + preferredPort: number; +} + +export async function findAvailablePort({ + host, + preferredPort, +}: AvailablePortOptions): Promise { + const preferred = await tryPort(host, preferredPort); + if (preferred !== undefined) return preferred; + + const available = await tryPort(host, 0); + if (available === undefined) throw new Error("operating system did not allocate a loopback port"); + return available; +} + +function tryPort(host: string, port: number): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.unref(); + server.once("error", (error: NodeJS.ErrnoException) => { + if (error.code === "EADDRINUSE") resolve(undefined); + else reject(error); + }); + server.listen(port, host, () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + reject(new Error(`could not resolve allocated loopback port for ${host}`)); + return; + } + server.close((error) => (error ? reject(error) : resolve(address.port))); + }); + }); +} + /** * Runs `go run ./cmd/playground` alongside the dev server, so evaluation goes * through the real gomplate engine rather than a reimplementation in the @@ -17,7 +57,7 @@ export interface EvalServerOptions { * when attaching a debugger, or when iterating on Go code that would otherwise * be recompiled on every Vite restart. */ -export function evalServer({ repoRoot, port }: EvalServerOptions): Plugin { +export function evalServer({ repoRoot, host, port }: EvalServerOptions): Plugin { let child: ChildProcess | undefined; const stop = () => { @@ -41,7 +81,7 @@ export function evalServer({ repoRoot, port }: EvalServerOptions): Plugin { return; } - child = spawn("go", ["run", "./cmd/playground", "-addr", `:${port}`], { + child = spawn("go", ["run", "./cmd/playground", "-addr", `${host}:${port}`], { cwd: repoRoot, stdio: ["ignore", "pipe", "pipe"], }); diff --git a/web/apps/playground/test/viteConfig.test.ts b/web/apps/playground/test/viteConfig.test.ts new file mode 100644 index 000000000..9b4dd809b --- /dev/null +++ b/web/apps/playground/test/viteConfig.test.ts @@ -0,0 +1,64 @@ +import { createServer } from "node:net"; +import { describe, expect, it } from "vitest"; + +import { findAvailablePort } from "../plugins/eval-server"; +import { createPlaygroundConfig } from "../vite.config"; + +const SELECTED_EVAL_PORT = 49_153; + +describe("playground Vite configuration", () => { + it.each([ + { command: "serve" as const, mode: "development", clickySourceAvailable: true, expected: true }, + { command: "serve" as const, mode: "test", clickySourceAvailable: true, expected: false }, + { command: "serve" as const, mode: "development", clickySourceAvailable: false, expected: false }, + { command: "build" as const, mode: "production", clickySourceAvailable: true, expected: false }, + ])( + "sets dependency re-optimization to $expected for command=$command mode=$mode source=$clickySourceAvailable", + ({ command, mode, clickySourceAvailable, expected }) => { + const config = createPlaygroundConfig({ + command, + mode, + clickySourceAvailable, + evalPort: SELECTED_EVAL_PORT, + }); + + expect(config.optimizeDeps?.force).toBe(expected); + }, + ); + + it("proxies API requests to the selected eval-server port", () => { + const config = createPlaygroundConfig({ + command: "serve", + mode: "development", + clickySourceAvailable: false, + evalPort: SELECTED_EVAL_PORT, + }); + + expect(config.server?.proxy?.["/api"]).toMatchObject({ + target: `http://127.0.0.1:${SELECTED_EVAL_PORT}`, + }); + }); + + it("selects another loopback port when the preferred port is occupied", async () => { + const occupied = createServer(); + await new Promise((resolve, reject) => { + occupied.once("error", reject); + occupied.listen(0, "127.0.0.1", resolve); + }); + + try { + const address = occupied.address(); + if (!address || typeof address === "string") throw new Error("test listener has no TCP port"); + const selectedPort = await findAvailablePort({ + host: "127.0.0.1", + preferredPort: address.port, + }); + + expect(selectedPort).not.toBe(address.port); + } finally { + await new Promise((resolve, reject) => { + occupied.close((error) => (error ? reject(error) : resolve())); + }); + } + }); +}); diff --git a/web/apps/playground/vite.config.ts b/web/apps/playground/vite.config.ts index ae9212238..4c9b9babf 100644 --- a/web/apps/playground/vite.config.ts +++ b/web/apps/playground/vite.config.ts @@ -3,13 +3,14 @@ import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; -import { defineConfig } from "vite"; +import { defineConfig, type UserConfig } from "vite"; -import { evalServer } from "./plugins/eval-server"; +import { evalServer, findAvailablePort } from "./plugins/eval-server"; const root = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(root, "../../.."); +const EVAL_HOST = "127.0.0.1"; const EVAL_PORT = 8321; const DEV_PORT = 5280; @@ -37,14 +38,26 @@ const clickyAliases = [ { find: /^@flanksource\/clicky-ui$/, replacement: resolve(clickySrc, "index.ts") }, ]; -export default defineConfig(({ command, mode }) => { +interface PlaygroundConfigOptions { + command: "build" | "serve"; + mode: string; + clickySourceAvailable: boolean; + evalPort: number; +} + +export function createPlaygroundConfig({ + command, + mode, + clickySourceAvailable, + evalPort, +}: PlaygroundConfigOptions): UserConfig { // Vitest also runs in `serve`, and it is the one mode that must not alias: // tests assert against the surface the package publishes, not against // whatever a sibling checkout happens to have mid-edit. - const useClickySource = clickySourceAvailable && mode !== "test"; + const useClickySource = command === "serve" && clickySourceAvailable && mode !== "test"; return { - plugins: [react(), tailwindcss(), evalServer({ repoRoot, port: EVAL_PORT })], + plugins: [react(), tailwindcss(), evalServer({ repoRoot, host: EVAL_HOST, port: evalPort })], resolve: { dedupe: ["react", "react-dom"], alias: command === "serve" && useClickySource ? clickyAliases : [], @@ -54,7 +67,7 @@ export default defineConfig(({ command, mode }) => { strictPort: true, proxy: { "/api": { - target: `http://127.0.0.1:${EVAL_PORT}`, + target: `http://${EVAL_HOST}:${evalPort}`, changeOrigin: false, }, }, @@ -62,10 +75,23 @@ export default defineConfig(({ command, mode }) => { fs: { allow: [root, resolve(root, "../.."), ...(useClickySource ? [clickySrc] : [])] }, }, optimizeDeps: { + // The sibling lockfile is outside Vite's cache inputs, so dependency + // paths can otherwise remain stale after clicky-ui upgrades a package. + force: useClickySource, exclude: [ "@flanksource/gomplate-lang", ...(useClickySource ? ["@flanksource/clicky-ui"] : []), ], }, }; +} + +export default defineConfig(async ({ command, mode }) => { + const managesEvalServer = process.env.GOMPLATE_PLAYGROUND_SERVER !== "0"; + const evalPort = + command === "serve" && mode !== "test" && managesEvalServer + ? await findAvailablePort({ host: EVAL_HOST, preferredPort: EVAL_PORT }) + : EVAL_PORT; + + return createPlaygroundConfig({ command, mode, clickySourceAvailable, evalPort }); }); From d402152a9456e9d11fe19c940944bc7c3667747d Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Tue, 25 Aug 2026 10:27:13 +0300 Subject: [PATCH 2/9] chore(web): Update pnpm to 11.22.0 with integrity hash --- web/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/package.json b/web/package.json index 3aaff9bc1..ed0106ea0 100644 --- a/web/package.json +++ b/web/package.json @@ -10,5 +10,5 @@ "typecheck": "pnpm -r typecheck", "dev:playground": "pnpm --filter gomplate-playground dev" }, - "packageManager": "pnpm@10.34.5" + "packageManager": "pnpm@11.22.0+sha512.1ff870c4c6133dfd88fb2afc46dd13d47f09c9794b438c6fdb47ca98caf3bc16381ee0be93a091b8e3824cf01f889f46d7d9e20910fb0be1ab0fb5baa80dd621" } From 952c2ffd2e1589330d635dec251b8a335dfed32d Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Wed, 16 Sep 2026 18:25:57 +0545 Subject: [PATCH 3/9] fix(template): ignore CacheKey when caching go templates An explicit CacheKey made the go template cache return wrong output. Two separate failures, both triggered solely by setting CacheKey: 1. Stale functions leak between calls. A parsed go template stores its function map internally; Execute takes fresh data but reuses whatever functions were bound at Parse time. The cache stored the fully parsed template, so the first caller's functions were served to every later caller: first render (cluster=production) -> node://production/node-a second render (cluster=staging) -> node://production/node-b Note the split result: .name is dot access and stays correct, while tags.cluster is a function call and is frozen. With ValueFunctions the caller cannot avoid this, since runGoTemplate manufactures a closure per environment key behind their back. Under concurrency it also mixes callers' data outright (node-4=cluster-7). 2. Distinct parses collide on one key. cacheKey() returned CacheKey verbatim, omitting the body and delimiters. runGoTemplate renders once per DelimSets entry, each pass parsing different text with different delimiters, so every pass shared one entry and later passes reused the first pass's template: "A={{ 1 }} B=$( 2 )" -> "A=1 B=$( 2 )" The $( ) pass silently never ran. Two unrelated templates sharing a CacheKey collided the same way. Neither guard that prevents this is new. IsCacheable() already refuses to cache anything carrying Functions, and autoCacheKey() already folds in the body and both delimiters. CacheKey bypassed both at once. CacheKey was added for cel (#157) and its doc comment speaks of sharing a compiled cel.Program. It belongs there: cel bakes functions into the program via celEnvOptions/env.Program and they cannot be rebound afterwards, so the caller's assertion that functions are interchangeable is the only way to make reuse safe. Go templates have no such constraint and never needed the escape hatch; they inherited it because both paths call the same two helpers. So the go template path now derives its key from the template itself and ignores CacheKey. isGoTemplateCacheable() is IsCacheable() minus the CacheKey bypass, leaving cel behaviour untouched. For anyone not setting CacheKey this is a no-op; for anyone setting it, output becomes correct. Templates carrying functions are consequently not cached and re-parse per render, which is already what correct usage costs today. Making that path fast needs the cached tree to be independent of function values and to hold only the built-ins it references, since Clone copies the whole function map (~20us for gomplate's 97 built-ins versus ~0.8us for a handful). That is a performance change with its own trade-offs and is left out of this fix. Tests cover each failure: value functions, caller-supplied functions, delimiter passes, two templates under one key, and concurrent renders. All five fail on the parent commit. --- template.go | 24 +++++-- template_test.go | 180 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 200 insertions(+), 4 deletions(-) diff --git a/template.go b/template.go index c6edeeae4..5c1010291 100644 --- a/template.go +++ b/template.go @@ -72,6 +72,11 @@ type Template struct { // template, bypassing the IsCacheable() heuristic. The caller asserts that // any two templates sharing this key may share the compiled cel.Program // (same expression semantics, same env shape, same function semantics). + // + // NOTE: CacheKey applies to cel expressions only. The go template path ignores + // it and always derives its key from the template body and delimiters. A parsed + // go template keeps the Functions it was parsed with, and the caller cannot + // assert those are interchangeable across calls; see isGoTemplateCacheable. CacheKey string `yaml:"-" json:"-"` // CacheTime controls how long the compiled program/template is retained @@ -170,6 +175,17 @@ func (t Template) IsCacheable() bool { return len(t.CelEnvs) == 0 && len(t.Functions) == 0 } +// isGoTemplateCacheable reports whether a parsed go template may be reused across +// calls. It is IsCacheable without the CacheKey bypass. +// +// A parsed go template retains the Functions that were bound when it was parsed, +// so reusing one would serve an earlier call's function values. An explicit +// CacheKey cannot make that safe, and it also collapses each delimiter pass onto +// a single entry, so the go template path ignores CacheKey entirely. +func (t Template) isGoTemplateCacheable() bool { + return len(t.CelEnvs) == 0 && len(t.Functions) == 0 +} + func (t Template) IsEmpty() bool { return t.Template == "" && t.JSONPath == "" && t.Expression == "" && t.Javascript == "" } @@ -293,8 +309,8 @@ func runGoTemplate(ctx commonsContext.Context, template Template, environment ma func goTemplate(ctx commonsContext.Context, template Template, environment map[string]any) (string, error) { var tpl *gotemplate.Template - if template.IsCacheable() { - cached, ok := goTemplateCache.Get(template.cacheKey(nil)) + if template.isGoTemplateCacheable() { + cached, ok := goTemplateCache.Get(template.autoCacheKey(nil)) if ok { if cachedTpl, ok := cached.(*gotemplate.Template); ok { if ctx.Logger != nil && properties.On(false, "gomplate.log") { @@ -329,8 +345,8 @@ func goTemplate(ctx commonsContext.Context, template Template, environment map[s return "", oops.With("template", template.Template).Wrap(err) } - if template.IsCacheable() { - goTemplateCache.Set(template.cacheKey(nil), tpl, template.CacheTime) + if template.isGoTemplateCacheable() { + goTemplateCache.Set(template.autoCacheKey(nil), tpl, template.CacheTime) } } diff --git a/template_test.go b/template_test.go index 93838b5ee..a92ab44b6 100644 --- a/template_test.go +++ b/template_test.go @@ -1,6 +1,8 @@ package gomplate import ( + "fmt" + "sync" "testing" "time" @@ -324,3 +326,181 @@ func TestRunExpressionReusesProgramAcrossDifferentData(t *testing.T) { t.Errorf("second eval result: got %v (cached program should not leak first-call data)", out2) } } + +// The go template path must ignore CacheKey. A parsed go template holds the +// Functions it was parsed with, and CacheKey used to bypass both the check that +// keeps such templates out of the cache and the key that keeps distinct parses +// apart. The tests below cover each way that used to go wrong. + +func TestGoTemplate_CacheKeyDoesNotReuseValueFunctions(t *testing.T) { + template := Template{ + Template: "node://$(tags.cluster)/$(.name)", + CacheKey: "gotemplate-value-functions", + CacheTime: time.Hour, + ValueFunctions: true, + DelimSets: []Delims{{Left: "$(", Right: ")"}}, + } + + first, err := RunTemplate(map[string]any{ + "name": "node-a", + "tags": map[string]string{"cluster": "production"}, + }, template) + if err != nil { + t.Fatalf("first render: %v", err) + } + if first != "node://production/node-a" { + t.Errorf("first render: got %q, want %q", first, "node://production/node-a") + } + + second, err := RunTemplate(map[string]any{ + "name": "node-b", + "tags": map[string]string{"cluster": "staging"}, + }, template) + if err != nil { + t.Fatalf("second render: %v", err) + } + if second != "node://staging/node-b" { + t.Errorf("second render used the first render's value functions: got %q, want %q", + second, "node://staging/node-b") + } +} + +func TestGoTemplate_CacheKeyDoesNotReuseCustomFunctions(t *testing.T) { + forUser := func(user string) Template { + return Template{ + Template: "user is {{ whoami }}", + CacheKey: "gotemplate-custom-functions", + CacheTime: time.Hour, + Functions: map[string]any{ + "whoami": func() any { return user }, + }, + } + } + + first, err := RunTemplate(map[string]any{}, forUser("alice")) + if err != nil { + t.Fatalf("first render: %v", err) + } + if first != "user is alice" { + t.Errorf("first render: got %q, want %q", first, "user is alice") + } + + second, err := RunTemplate(map[string]any{}, forUser("bob")) + if err != nil { + t.Fatalf("second render: %v", err) + } + if second != "user is bob" { + t.Errorf("second render used the first render's functions: got %q, want %q", + second, "user is bob") + } +} + +func TestGoTemplate_CacheKeyDoesNotCollideAcrossDelimiterPasses(t *testing.T) { + // Each pass parses different text with different delimiters. Sharing one + // CacheKey made the second pass reuse the first pass's parsed template, so + // the $( ) placeholder was never resolved. + out, err := RunTemplate(map[string]any{}, Template{ + Template: "A={{ 1 }} B=$( 2 )", + CacheKey: "gotemplate-delimiter-passes", + CacheTime: time.Hour, + DelimSets: []Delims{ + {Left: "{{", Right: "}}"}, + {Left: "$(", Right: ")"}, + }, + }) + if err != nil { + t.Fatalf("render: %v", err) + } + if out != "A=1 B=2" { + t.Errorf("second delimiter pass did not run: got %q, want %q", out, "A=1 B=2") + } +} + +func TestGoTemplate_CacheKeyDoesNotCollideAcrossTemplates(t *testing.T) { + first, err := RunTemplate(map[string]any{}, Template{ + Template: "I am template A", + CacheKey: "gotemplate-shared-label", + CacheTime: time.Hour, + }) + if err != nil { + t.Fatalf("first render: %v", err) + } + if first != "I am template A" { + t.Errorf("first render: got %q, want %q", first, "I am template A") + } + + second, err := RunTemplate(map[string]any{}, Template{ + Template: "I am template B", + CacheKey: "gotemplate-shared-label", + CacheTime: time.Hour, + }) + if err != nil { + t.Fatalf("second render: %v", err) + } + if second != "I am template B" { + t.Errorf("templates sharing a CacheKey collided: got %q, want %q", + second, "I am template B") + } +} + +func TestGoTemplate_CacheKeyIsSafeForConcurrentRenders(t *testing.T) { + template := Template{ + Template: "$(.name)=$(tags.cluster)", + CacheKey: "gotemplate-concurrent", + CacheTime: time.Hour, + ValueFunctions: true, + DelimSets: []Delims{{Left: "$(", Right: ")"}}, + } + + const workers = 8 + errs := make(chan error, workers) + var wg sync.WaitGroup + for i := 0; i < workers; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + name := fmt.Sprintf("node-%d", i) + cluster := fmt.Sprintf("cluster-%d", i) + want := name + "=" + cluster + for j := 0; j < 100; j++ { + got, err := RunTemplate(map[string]any{ + "name": name, + "tags": map[string]string{"cluster": cluster}, + }, template) + if err != nil { + errs <- err + return + } + if got != want { + errs <- fmt.Errorf("got %q, want %q", got, want) + return + } + } + }(i) + } + wg.Wait() + close(errs) + for err := range errs { + t.Error(err) + } +} + +// isGoTemplateCacheable must not honour CacheKey, unlike IsCacheable. +func TestIsGoTemplateCacheable(t *testing.T) { + withFuncs := Template{ + Template: "{{ hello }}", + Functions: map[string]any{"hello": func() any { return "world" }}, + CacheKey: "stable", + } + if !withFuncs.IsCacheable() { + t.Error("IsCacheable must still honour CacheKey for cel expressions") + } + if withFuncs.isGoTemplateCacheable() { + t.Error("a go template with Functions must not be cached, even with a CacheKey") + } + + plain := Template{Template: "{{ .name }}", CacheKey: "stable"} + if !plain.isGoTemplateCacheable() { + t.Error("a go template without Functions must be cacheable") + } +} From a7950671edbfe5cd788470a2a6bcbcf8a6e74970 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Sun, 1 Nov 2026 11:15:39 +0200 Subject: [PATCH 4/9] dependency(deps): update Go toolchain, Go modules, and clicky-ui to latest releases Bumps the Go toolchain to 1.26.1 and refreshes direct and indirect modules (flanksource commons and is-healthy, ginkgo, gomega, otel, golang.org/x packages, and others) to newer releases. Removes indirect requirements no longer in the build graph, such as cert-manager, gateway-api, and apiextensions-apiserver. Upgrades @flanksource/clicky-ui in the playground to 0.3.54, which adds @tanstack/react-virtual and country-flag-icons, moves tailwind-merge to v3, and drops tailwindcss v3 from its peer range. --- go.mod | 50 ++++++-------- go.sum | 108 ++++++++++++------------------- web/apps/playground/package.json | 2 +- web/pnpm-lock.yaml | 47 +++++++++++--- 4 files changed, 98 insertions(+), 109 deletions(-) diff --git a/go.mod b/go.mod index 8cb12cfd5..e13591cfe 100644 --- a/go.mod +++ b/go.mod @@ -1,14 +1,14 @@ module github.com/flanksource/gomplate/v3 -go 1.26.0 +go 1.26.1 require ( github.com/Masterminds/goutils v1.1.1 - github.com/Masterminds/semver/v3 v3.4.0 + github.com/Masterminds/semver/v3 v3.5.0 github.com/antchfx/xmlquery v1.5.1 github.com/antlr4-go/antlr/v4 v4.13.1 - github.com/flanksource/commons v1.53.1 - github.com/flanksource/is-healthy v1.0.90 + github.com/flanksource/commons v1.63.0 + github.com/flanksource/is-healthy v1.0.93 github.com/flanksource/kubectl-neat v1.0.4 github.com/google/cel-go v0.31.0 github.com/google/go-cmp v0.7.0 @@ -19,8 +19,8 @@ require ( github.com/jmespath/go-jmespath v0.4.1-0.20220621161143-b0104c826a24 github.com/mitchellh/reflectwalk v1.0.2 github.com/ohler55/ojg v1.28.0 - github.com/onsi/ginkgo/v2 v2.28.0 - github.com/onsi/gomega v1.39.1 + github.com/onsi/ginkgo/v2 v2.32.1 + github.com/onsi/gomega v1.42.1 github.com/patrickmn/go-cache v2.1.0+incompatible github.com/pkg/errors v0.9.1 github.com/robertkrimen/otto v0.5.1 @@ -28,8 +28,8 @@ require ( github.com/stretchr/testify v1.11.1 github.com/timberio/go-datemath v0.1.0 github.com/ugorji/go/codec v1.3.1 - golang.org/x/text v0.37.0 - golang.org/x/tools v0.44.0 + golang.org/x/text v0.40.0 + golang.org/x/tools v0.47.0 google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af gopkg.in/yaml.v3 v3.0.1 gotest.tools/v3 v3.5.1 @@ -47,33 +47,22 @@ require ( github.com/antchfx/xpath v1.3.6 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bsm/gomega v1.27.10 // indirect - github.com/cert-manager/cert-manager v1.19.4 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/distribution/reference v0.6.0 // indirect - github.com/emirpasic/gods/v2 v2.0.0-alpha // indirect - github.com/fatih/color v1.18.0 // indirect - github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/ghodss/yaml v1.0.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/gobwas/glob v0.2.3 // indirect - github.com/goccy/go-yaml v1.19.2 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect - github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect + github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect github.com/gosimple/unidecode v1.0.1 // indirect github.com/hexops/gotextdiff v1.0.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/itchyny/timefmt-go v0.1.7 // indirect github.com/jeremywohl/flatten v1.0.1 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/kr/pretty v0.3.1 // indirect - github.com/kr/text v0.2.0 // indirect - github.com/lmittmann/tint v1.1.3 // indirect - github.com/lrita/cmap v0.0.0-20231108122212-cb084a67f554 // indirect - github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect @@ -82,10 +71,9 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.67.5 // indirect + github.com/prometheus/common v0.68.1 // indirect github.com/prometheus/procfs v0.20.1 // indirect github.com/robfig/cron/v3 v3.0.1 // indirect - github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/samber/lo v1.53.0 // indirect github.com/sirupsen/logrus v1.9.4 // indirect github.com/spf13/cobra v1.10.2 // indirect @@ -96,27 +84,25 @@ require ( github.com/tidwall/sjson v1.2.5 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/yuin/gopher-lua v1.1.1 // indirect - go.opentelemetry.io/otel v1.41.0 // indirect - go.opentelemetry.io/otel/trace v1.41.0 // indirect - go.yaml.in/yaml/v2 v2.4.3 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect + go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect - golang.org/x/mod v0.35.0 // indirect - golang.org/x/net v0.55.0 // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.45.0 // indirect + golang.org/x/exp v0.0.0-20260527015227-08cc5374adb3 // indirect + golang.org/x/mod v0.37.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.46.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/sourcemap.v1 v1.0.5 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect - k8s.io/apiextensions-apiserver v0.36.1 // indirect k8s.io/client-go v0.36.1 // indirect k8s.io/klog/v2 v2.140.0 // indirect k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect layeh.com/gopher-json v0.0.0-20201124131017-552bb3c4c3bf // indirect - sigs.k8s.io/gateway-api v1.5.0 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect diff --git a/go.sum b/go.sum index f0c21eecc..39e811d91 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= -github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= -github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= +github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/antchfx/xmlquery v1.5.1 h1:T9I4Ns1EXiWHy0IqKupGhnfTQtJwlGrpXtauYOoNv78= github.com/antchfx/xmlquery v1.5.1/go.mod h1:bVqnl7TaDXSReKINrhZz+2E/PbCu2tUahb+wZ7WZNT8= github.com/antchfx/xpath v1.3.6 h1:s0y+ElRRtTQdfHP609qFu0+c6bglDv20pqOViQjjdPI= @@ -12,34 +12,25 @@ github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYW github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38= -github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= +github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= -github.com/cert-manager/cert-manager v1.19.4 h1:7lOkSYj+nJNjgGFfAznQzPpOfWX+1Kgz6xUXwTa/K5k= -github.com/cert-manager/cert-manager v1.19.4/go.mod h1:9uBnn3IK9NxjjuXmQDYhwOwFUU5BtGVB1g/voPvvcVw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/emirpasic/gods/v2 v2.0.0-alpha h1:dwFlh8pBg1VMOXWGipNMRt8v96dKAIvBehtCt6OtunU= -github.com/emirpasic/gods/v2 v2.0.0-alpha/go.mod h1:W0y4M2dtBB9U5z3YlghmpuUhiaZT2h6yoeE+C1sCp6A= -github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= -github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= -github.com/flanksource/commons v1.53.1 h1:WiMvY9XGG//L4ndYKTcmp0NjWXg4B7Wbn4hYWkITUmY= -github.com/flanksource/commons v1.53.1/go.mod h1:ZII22jIDJ3fd/Mz7l0SzLFEv8aoSUg+xwfJAAVf8up4= -github.com/flanksource/is-healthy v1.0.90 h1:YV4rmL9y1BOgg0FZsWF7FgMBiHyf1FS3oJG6mB8o4VY= -github.com/flanksource/is-healthy v1.0.90/go.mod h1:fTjFuG/3G4NFLEmaOAyyzlZB0i0CCBpvjq3UqceSfSg= +github.com/flanksource/commons v1.63.0 h1:gI5DecATKbay5Q1o1hYRxQUSnbC+yWP7BmdHQjrpvTY= +github.com/flanksource/commons v1.63.0/go.mod h1:RlVSQ+Jq96jKaJWf3WnwVO4DmDTdzkm3sremzJuYuVw= +github.com/flanksource/is-healthy v1.0.93 h1:hpXkPfMXG+zawNs0HTjc/3ZIjrW7obAPDR/VoTJk59Q= +github.com/flanksource/is-healthy v1.0.93/go.mod h1:Ee0lzss7zeLlfYOcr4r3eOUJqTD3fjVnxT5cv79UVF8= github.com/flanksource/kubectl-neat v1.0.4 h1:t5/9CqgE84oEtB0KitgJ2+WIeLfD+RhXSxYrqb4X8yI= github.com/flanksource/kubectl-neat v1.0.4/go.mod h1:Un/Voyh3cmiZNKQrW/TkAl28nAA7vwnwDGVjRErKjOw= -github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= -github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= @@ -68,8 +59,8 @@ github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc= -github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= +github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg= +github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gosimple/slug v1.15.0 h1:wRZHsRrRcs6b0XnxMUBM6WK1U1Vg5B0R7VkIf1Xzobo= @@ -102,16 +93,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/lmittmann/tint v1.1.3 h1:Hv4EaHWXQr+GTFnOU4VKf8UvAtZgn0VuKT+G0wFlO3I= -github.com/lmittmann/tint v1.1.3/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= -github.com/lrita/cmap v0.0.0-20231108122212-cb084a67f554 h1:a0+bIffIh/HdvvgtPQLRhOef1VDSxZ+8bQiyjQlJzqc= -github.com/lrita/cmap v0.0.0-20231108122212-cb084a67f554/go.mod h1:Cn9TaoncDT8Tt/aJ7CIZy+t48MaZWDEwhu1bBXwrzLI= github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= -github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= -github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= @@ -128,16 +111,15 @@ github.com/ohler55/ojg v1.28.0 h1:8xClBgMIRRJGDUC9xNe7NprP4kD2C3mQMeon3wY4KXA= github.com/ohler55/ojg v1.28.0/go.mod h1:/Y5dGWkekv9ocnUixuETqiL58f+5pAsUfg5P8e7Pa2o= github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= -github.com/onsi/ginkgo/v2 v2.28.0 h1:Rrf+lVLmtlBIKv6KrIGJCjyY8N36vDVcutbGJkyqjJc= -github.com/onsi/ginkgo/v2 v2.28.0/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= -github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= -github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= +github.com/onsi/ginkgo/v2 v2.32.1 h1:6tlvcDm/3sE8lGJbZ4+d4mO3RLy24/tQWOFzVSQNIfw= +github.com/onsi/ginkgo/v2 v2.32.1/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= +github.com/onsi/gomega v1.42.1 h1:iN1rCUX+44NZ1Dc97MPoeFYbFR0vh8zxoxMFwKdyZ6I= +github.com/onsi/gomega v1.42.1/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= -github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -147,15 +129,14 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= -github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/common v0.68.1 h1:omjRRl4QP4komogpXuhfeOiisQg7xdy8VM1UY+pStaY= +github.com/prometheus/common v0.68.1/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y= github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/robertkrimen/otto v0.5.1 h1:avDI4ToRk8k1hppLdYFTuuzND41n37vPGJU7547dGf0= github.com/robertkrimen/otto v0.5.1/go.mod h1:bS433I4Q9p+E5pZLu7r17vP6FkE6/wLxBdmKjoqJXF8= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -196,20 +177,20 @@ github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -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/exporters/stdout/stdouttrace v1.35.0 h1:T0Ec2E+3YZf5bgTNQVet8iTDW7oIk03tXHq+wkwIDnE= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.35.0/go.mod h1:30v2gqH+vYGJsesLWFov8u47EpYTcIQcBjKpI6pJThg= -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/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= -go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= -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 v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0 h1:bl2S7Ubua0Nms+D/gAmznQTd4dxxMA93aKbcpKqiTCs= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0/go.mod h1:L0hRV50XdVIODHUfWEqGRCXQvj2rV82STVo12FMFBU0= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= -go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -218,15 +199,15 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= -golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= +golang.org/x/exp v0.0.0-20260527015227-08cc5374adb3 h1:VHEvKbpgPXcPXn40t9cDTGK3JZwMikIEyF/CTrFfu7k= +golang.org/x/exp v0.0.0-20260527015227-08cc5374adb3/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= -golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= @@ -236,8 +217,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -245,22 +226,21 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -279,16 +259,16 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= -golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 h1:tu/dtnW1o3wfaxCOjSLn5IRX4YDcJrtlpzYkhHhGaC4= google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng= @@ -312,8 +292,6 @@ gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= k8s.io/api v0.36.1 h1:XbL/EMj8K2aJpJtePmqUyQMsM0D4QI2pvl7YKJ20FTY= k8s.io/api v0.36.1/go.mod h1:KOWo4ey3TINlXjeHVuwB3i+tXXnu+UcwFBHlI/9dvEo= -k8s.io/apiextensions-apiserver v0.36.1 h1:6JfYmPUsuUIHuN+3QxutXYWj492RqF5fBSx67GYK5Ks= -k8s.io/apiextensions-apiserver v0.36.1/go.mod h1:pLzZin90riwisdzKwv/GoTwENooytoIx5zWJb4Hkby8= k8s.io/apimachinery v0.36.2 h1:0PE/W/WNy1UX61NLbXY5TMbJ6UwLL6E6lAPkYrKFxbQ= k8s.io/apimachinery v0.36.2/go.mod h1:fvf/HOLXq9RId0rnDIbN1OEBvHXdQbLMM8nu0LcBUf4= k8s.io/client-go v0.36.1 h1:FN/K8QIT2CEDt+2WB2HnWrUANZ50AP5GII43/SP2JR0= @@ -326,8 +304,6 @@ k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0x k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= layeh.com/gopher-json v0.0.0-20201124131017-552bb3c4c3bf h1:rRz0YsF7VXj9fXRF6yQgFI7DzST+hsI3TeFSGupntu0= layeh.com/gopher-json v0.0.0-20201124131017-552bb3c4c3bf/go.mod h1:ivKkcY8Zxw5ba0jldhZCYYQfGdb2K6u9tbYK1AwMIBc= -sigs.k8s.io/gateway-api v1.5.0 h1:duoo14Ky/fJXpjpmyMISE2RTBGnfCg8zICfTYLTnBJA= -sigs.k8s.io/gateway-api v1.5.0/go.mod h1:GvCETiaMAlLym5CovLxGjS0NysqFk3+Yuq3/rh6QL2o= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= diff --git a/web/apps/playground/package.json b/web/apps/playground/package.json index 73b76940d..d6b7504da 100644 --- a/web/apps/playground/package.json +++ b/web/apps/playground/package.json @@ -11,7 +11,7 @@ "test": "vitest run --passWithNoTests" }, "dependencies": { - "@flanksource/clicky-ui": "^0.3.19", + "@flanksource/clicky-ui": "^0.3.54", "@flanksource/gomplate-lang": "workspace:*", "monaco-editor": "catalog:", "react": "catalog:", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 96f252c03..fc822927f 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -56,8 +56,8 @@ importers: apps/playground: dependencies: '@flanksource/clicky-ui': - specifier: ^0.3.19 - version: 0.3.20(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.2.18)(monaco-editor@0.48.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(recharts@3.10.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.8)(react@19.2.8)(redux@5.0.1))(tailwindcss@4.3.3) + specifier: ^0.3.54 + version: 0.3.54(@babel/core@7.29.7)(@babel/template@7.29.7)(@tanstack/react-query@5.101.4(react@19.2.8))(@types/react@19.2.18)(monaco-editor@0.48.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(recharts@3.10.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.8)(react@19.2.8)(redux@5.0.1))(tailwindcss@4.3.3) '@flanksource/gomplate-lang': specifier: workspace:* version: link:../../packages/lang @@ -413,14 +413,15 @@ packages: cpu: [x64] os: [win32] - '@flanksource/clicky-ui@0.3.20': - resolution: {integrity: sha512-FByvZ67fymsUNVtO3HvbpUmF9XzNj1cC9rQtY2wmMmmOHaG2xLgyzmUE1zaOB2oVYUwhhbpj6XQKCeHZxZ0vsw==} + '@flanksource/clicky-ui@0.3.54': + resolution: {integrity: sha512-iLgY/9So/X9AdnTkJu18GYXLWybFHdfZz7CYjyu+/jBuvwGtZkSY0cssZND2gKMHV8aQOh2MO1Nk1RMJfdyM1A==} peerDependencies: '@ai-sdk/react': ^3.0.0 '@mdxeditor/editor': ^4.0.4 '@shikijs/langs': ^1.24.0 '@shikijs/themes': ^1.24.0 '@shikijs/transformers': ^1.24.0 + '@tanstack/react-query': ^5.66.8 ai: ^6.0.0 marked: ^15.0.0 monaco-editor: '>=0.48 <1' @@ -430,7 +431,7 @@ packages: recharts: ^3.0.0 shiki: ^1.24.0 streamdown: ^2.5.0 - tailwindcss: ^3.4.0 || ^4.0.0 + tailwindcss: ^4.0.0 peerDependenciesMeta: '@ai-sdk/react': optional: true @@ -805,6 +806,15 @@ packages: peerDependencies: react: ^18 || ^19 + '@tanstack/react-virtual@3.14.24': + resolution: {integrity: sha512-JoNsByXR68fNlB2NZuT3bSk8uYBxuOWl6YNJ7djTrw7IMwBWuaOtffxj/TcuMa80WmL6byb+80i1NEM/8Qmocw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/virtual-core@3.19.9': + resolution: {integrity: sha512-Syiaf3w69dFyYv4o2mqJ1/2VtBJTNq5Hu5cVXfnzOuJKFpUojukQR13ZW+5NvdMJM7GFHeNPmveMNhoIubiFyA==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -951,6 +961,9 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + country-flag-icons@1.6.20: + resolution: {integrity: sha512-py8JiEKzjhYw6HPJ0L7SxLgCYim36UPRTZX43/kqGueUCZLSvnrqAiwW8HtQibur7mdkFQUkjOgdK+o/9FBtaw==} + crelt@1.0.7: resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==} @@ -1421,8 +1434,8 @@ packages: tabbable@6.5.0: resolution: {integrity: sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==} - tailwind-merge@2.6.1: - resolution: {integrity: sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==} + tailwind-merge@3.7.0: + resolution: {integrity: sha512-XPPUyAc+cvspz3lHTcR/QgPfW2A0lv/xQNIjX3HGhLR+Nq2lHaLq5MtTesHn8GUr3W3DguT2KT5x3NVgRtYwmA==} tailwindcss@4.3.3: resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} @@ -1906,12 +1919,13 @@ snapshots: '@esbuild/win32-x64@0.25.12': optional: true - '@flanksource/clicky-ui@0.3.20(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.2.18)(monaco-editor@0.48.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(recharts@3.10.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.8)(react@19.2.8)(redux@5.0.1))(tailwindcss@4.3.3)': + '@flanksource/clicky-ui@0.3.54(@babel/core@7.29.7)(@babel/template@7.29.7)(@tanstack/react-query@5.101.4(react@19.2.8))(@types/react@19.2.18)(monaco-editor@0.48.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(recharts@3.10.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.8)(react@19.2.8)(redux@5.0.1))(tailwindcss@4.3.3)': dependencies: '@codemirror/autocomplete': 6.20.3 '@codemirror/commands': 6.10.4 '@codemirror/lang-json': 6.0.2 '@codemirror/lang-sql': 6.10.0 + '@codemirror/language': 6.12.4 '@codemirror/state': 6.7.1 '@codemirror/view': 6.43.8 '@flanksource/icons': 1.0.63(react@19.2.8) @@ -1920,16 +1934,19 @@ snapshots: '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8) '@tanstack/react-query': 5.101.4(react@19.2.8) + '@tanstack/react-virtual': 3.14.24(react-dom@19.2.8(react@19.2.8))(react@19.2.8) class-variance-authority: 0.7.1 clsx: 2.1.1 + country-flag-icons: 1.6.20 dompurify: 3.4.13 jotai: 2.20.2(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.2.18)(react@19.2.8) + jsonc-parser: 3.3.1 monaco-editor: 0.48.0 monaco-yaml: 5.1.1(monaco-editor@0.48.0) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) recharts: 3.10.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.8)(react@19.2.8)(redux@5.0.1) - tailwind-merge: 2.6.1 + tailwind-merge: 3.7.0 tailwindcss: 4.3.3 yaml: 2.9.0 transitivePeerDependencies: @@ -2198,6 +2215,14 @@ snapshots: '@tanstack/query-core': 5.101.4 react: 19.2.8 + '@tanstack/react-virtual@3.14.24(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@tanstack/virtual-core': 3.19.9 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + + '@tanstack/virtual-core@3.19.9': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.8 @@ -2361,6 +2386,8 @@ snapshots: convert-source-map@2.0.0: {} + country-flag-icons@1.6.20: {} + crelt@1.0.7: {} cssstyle@4.6.0: @@ -2805,7 +2832,7 @@ snapshots: tabbable@6.5.0: {} - tailwind-merge@2.6.1: {} + tailwind-merge@3.7.0: {} tailwindcss@4.3.3: {} From 9ac4a16aa464d57290a131fc15062301ee6a5ab5 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Wed, 4 Nov 2026 16:30:45 +0200 Subject: [PATCH 5/9] feat(cel): add collection functions for grouping, selecting and joining rows Lets CEL expressions group, select and join native lists and maps without JSON or string round trips, preserving integer precision and host objects. Adds groupBy, distinctBy, latestBy, one and joinBy, plus exact decimal sum for lists of decimal strings. Strict templates fail on missing values and use a separate expression cache from nil-safe expressions. --- cel_collections.go | 187 ++++++++++++++++++++++++++++++++ cel_collections_spec_test.go | 60 ++++++++++ docs/content/cel-collections.md | 30 +++++ 3 files changed, 277 insertions(+) create mode 100644 cel_collections.go create mode 100644 cel_collections_spec_test.go create mode 100644 docs/content/cel-collections.md diff --git a/cel_collections.go b/cel_collections.go new file mode 100644 index 000000000..6f5d19e11 --- /dev/null +++ b/cel_collections.go @@ -0,0 +1,187 @@ +package gomplate + +import ( + "fmt" + + "github.com/google/cel-go/cel" + "github.com/google/cel-go/common/ast" + "github.com/google/cel-go/common/operators" + "github.com/google/cel-go/common/types" + "github.com/google/cel-go/common/types/ref" + "github.com/google/cel-go/common/types/traits" +) + +func collectionCelOptions() []cel.EnvOption { + options := []cel.EnvOption{cel.Macros(cel.ReceiverMacro("joinBy", 5, joinByMacro))} + for name, binding := range map[string]func(ref.Val) ref.Val{ + "groupBy": groupRows, "distinctBy": distinctRows, "latestBy": latestRow, + } { + options = append(options, + cel.Macros(cel.ReceiverMacro(name, 2, collectionKeyMacro(name))), + cel.Function("gomplate.@"+name, cel.Overload("collection_"+name, []*cel.Type{cel.ListType(cel.DynType)}, cel.DynType, cel.UnaryBinding(binding))), + ) + } + return append(options, + cel.Function("gomplate.@joinBy", cel.Overload("collection_joinBy", []*cel.Type{cel.ListType(cel.DynType), cel.ListType(cel.DynType)}, cel.ListType(cel.DynType), cel.BinaryBinding(joinRows))), + cel.Function("one", cel.MemberOverload("collection_one", []*cel.Type{cel.ListType(cel.DynType)}, cel.DynType, cel.UnaryBinding(oneRow))), + ) +} + +func collectionKeyMacro(name string) cel.MacroFactory { + return func(factory cel.MacroExprFactory, target ast.Expr, args []ast.Expr) (ast.Expr, *cel.Error) { + projection, err := keyedRowsMacro(factory, target, args) + if err != nil { + return nil, err + } + return factory.NewCall("gomplate.@"+name, projection), nil + } +} + +func keyedRowsMacro(factory cel.MacroExprFactory, target ast.Expr, args []ast.Expr) (ast.Expr, *cel.Error) { + variable, err := extractFoldIdent(factory, args[0]) + if err != nil { + return nil, err + } + accumulator := "@collection:" + fmt.Sprint(target.ID()) + return factory.NewComprehension(target, variable, accumulator, + factory.NewList(), factory.NewLiteral(types.True), + factory.NewCall(operators.Add, factory.NewIdent(accumulator), factory.NewList(factory.NewList(factory.NewIdent(variable), args[1]))), + factory.NewIdent(accumulator)), nil +} + +func joinByMacro(factory cel.MacroExprFactory, target ast.Expr, args []ast.Expr) (ast.Expr, *cel.Error) { + left, err := keyedRowsMacro(factory, target, args[1:3]) + if err != nil { + return nil, err + } + right, err := keyedRowsMacro(factory, args[0], args[3:5]) + if err != nil { + return nil, err + } + return factory.NewCall("gomplate.@joinBy", left, right), nil +} + +type collectionGroup struct { + key ref.Val + rows []ref.Val +} + +func indexedGroups(value ref.Val) ([]collectionGroup, map[any]int, ref.Val) { + list, ok := value.(traits.Lister) + if !ok { + return nil, nil, types.NewErr("groupBy requires a list") + } + groups, index := []collectionGroup{}, map[any]int{} + for iterator := list.Iterator(); iterator.HasNext() == types.True; { + pair := iterator.Next().(traits.Lister) + row, key := pair.Get(types.IntZero), pair.Get(types.Int(1)) + if types.IsError(key) || types.IsUnknown(key) { + return nil, nil, key + } + switch key.(type) { + case types.String, types.Int, types.Uint, types.Bool: + default: + return nil, nil, types.NewErr("groupBy key must be a string, integer or boolean, got %s", key.Type()) + } + identity := any(key) + position, exists := index[identity] + if !exists { + position = len(groups) + index[identity] = position + groups = append(groups, collectionGroup{key: key}) + } + groups[position].rows = append(groups[position].rows, row) + } + return groups, index, nil +} + +func groupRows(value ref.Val) ref.Val { + groups, _, err := indexedGroups(value) + if err != nil { + return err + } + rows := make([]ref.Val, len(groups)) + for index, group := range groups { + rows[index] = types.NewRefValMap(types.DefaultTypeAdapter, map[ref.Val]ref.Val{ + types.String("key"): group.key, types.String("rows"): types.NewRefValList(types.DefaultTypeAdapter, group.rows), + }) + } + return types.NewRefValList(types.DefaultTypeAdapter, rows) +} + +func distinctRows(value ref.Val) ref.Val { + groups, _, err := indexedGroups(value) + if err != nil { + return err + } + rows := make([]ref.Val, len(groups)) + for index, group := range groups { + rows[index] = group.rows[0] + } + return types.NewRefValList(types.DefaultTypeAdapter, rows) +} + +func oneRow(value ref.Val) ref.Val { + list := value.(traits.Lister) + if list.Size() != types.Int(1) { + return types.NewErr("one requires exactly one row, got %v", list.Size()) + } + return list.Get(types.IntZero) +} + +func latestRow(value ref.Val) ref.Val { + list := value.(traits.Lister) + if list.Size() == types.IntZero { + return types.NewErr("latestBy requires at least one row") + } + var latest, key ref.Val + tied := false + for iterator := list.Iterator(); iterator.HasNext() == types.True; { + pair := iterator.Next().(traits.Lister) + candidate := pair.Get(types.Int(1)) + comparer, ok := candidate.(traits.Comparer) + if !ok || types.IsError(candidate) || types.IsUnknown(candidate) { + return types.NewErr("latestBy requires comparable keys, got %s", candidate.Type()) + } + if key == nil { + latest, key = pair.Get(types.IntZero), candidate + continue + } + comparison := comparer.Compare(key) + if types.IsError(comparison) { + return comparison + } + if comparison == types.Int(1) { + latest, key, tied = pair.Get(types.IntZero), candidate, false + } else if comparison == types.IntZero { + tied = true + } + } + if tied { + return types.NewErr("latestBy has multiple rows at the latest key %v", key) + } + return latest +} + +func joinRows(left, right ref.Val) ref.Val { + _, _, err := indexedGroups(left) + if err != nil { + return err + } + rightGroups, index, err := indexedGroups(right) + if err != nil { + return err + } + rows := []ref.Val{} + for iterator := left.(traits.Lister).Iterator(); iterator.HasNext() == types.True; { + pair := iterator.Next().(traits.Lister) + position, exists := index[any(pair.Get(types.Int(1)))] + if !exists { + continue + } + for _, rightRow := range rightGroups[position].rows { + rows = append(rows, types.NewRefValMap(types.DefaultTypeAdapter, map[ref.Val]ref.Val{types.String("left"): pair.Get(types.IntZero), types.String("right"): rightRow})) + } + } + return types.NewRefValList(types.DefaultTypeAdapter, rows) +} diff --git a/cel_collections_spec_test.go b/cel_collections_spec_test.go new file mode 100644 index 000000000..0767b9db7 --- /dev/null +++ b/cel_collections_spec_test.go @@ -0,0 +1,60 @@ +package gomplate + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("verification collection functions", func() { + It("returns grouped native integers without a JSON number round trip", func() { + value, err := RunExpression(nil, Template{Expression: `[{"amount":9007199254740993}].groupBy(r,"fund").one().rows`, Strict: true}) + Expect(err).NotTo(HaveOccurred()) + Expect(value).To(Equal([]any{map[string]any{"amount": int64(9007199254740993)}})) + }) + It("groups collections supplied by the host environment", func() { + value, err := RunExpression(map[string]any{"rows": []map[string]any{{"fund": "Employee", "amount": 20}}}, Template{ + Expression: `rows.groupBy(r,r.fund).one().rows.one().amount == 20`, Strict: true, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(value).To(BeTrue()) + }) + It("keeps strict verification separate from nil-safe cached expressions", func() { + env := map[string]any{"row": map[string]any{}} + _, err := RunExpression(env, Template{Expression: "row.missing == 0", Strict: true}) + Expect(err).To(HaveOccurred()) + value, err := RunExpression(env, Template{Expression: "row.missing == 0"}) + Expect(err).NotTo(HaveOccurred()) + Expect(value).To(BeTrue()) + _, err = RunExpression(env, Template{Expression: "row.missing == 0", Strict: true}) + Expect(err).To(HaveOccurred()) + }) + DescribeTable("preserves keys, order and native row values", + func(expression string) { + value, err := RunExpression(nil, Template{Expression: expression}) + Expect(err).NotTo(HaveOccurred()) + Expect(value).To(BeTrue()) + }, + Entry("grouped rows", `[{'fund':'a','amount':2}, {'fund':'b','amount':4}, {'fund':'a','amount':3}].groupBy(r, r.fund).map(g, {'fund':g.key, 'total':g.rows.map(r,r.amount).sum()}) == [{'fund':'a','total':5}, {'fund':'b','total':4}]`), + Entry("empty grouping", `[].groupBy(r, r.fund) == []`), + Entry("typed keys", `[{'key':1}, {'key':'1'}].groupBy(r,r.key).size() == 2`), + Entry("first distinct", `[{'key':'a','value':1}, {'key':'a','value':2}, {'key':'b','value':3}].distinctBy(r,r.key).map(r,r.value) == [1,3]`), + Entry("latest row", `[{'date':2,'value':'new'}, {'date':1,'value':'old'}].latestBy(r,r.date).value == 'new'`), + Entry("one row", `[{'amount':12}].one().amount == 12`), + Entry("exact decimal sum", `['9007199254740993.01','0.09'].sum() == '9007199254740993.1'`), + Entry("decimal cancellation", `['1.20','-1.20'].sum() == '0'`), + Entry("join", `[{'fund':'a','amount':3}].joinBy([{'id':'a','amount':7}, {'id':'b','amount':4}], l,l.fund,r,r.id).one().right.amount == 7`), + ) + DescribeTable("rejects ambiguous or invalid evidence", + func(expression string, message string) { + _, err := RunExpression(nil, Template{Expression: expression}) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring(message)) + }, + Entry("missing single row", `[].one()`, "exactly one row"), + Entry("multiple single rows", `[1,2].one()`, "exactly one row"), + Entry("empty latest", `[].latestBy(r,r.date)`, "latestBy requires at least one row"), + Entry("tied latest", `[{'date':1}, {'date':1}].latestBy(r,r.date)`, "latestBy has multiple rows"), + Entry("null group key", `[{'key':null}].groupBy(r,r.key)`, "groupBy key"), + Entry("invalid decimal sum", `['1300','KES'].sum()`, "sum requires decimal strings"), + ) +}) diff --git a/docs/content/cel-collections.md b/docs/content/cel-collections.md new file mode 100644 index 000000000..588de6ba7 --- /dev/null +++ b/docs/content/cel-collections.md @@ -0,0 +1,30 @@ +--- +title: CEL Collections +weight: 14 +menu: main +--- + +CEL expressions can group, select and join native collections without converting them to JSON or strings. + +```cel +rows.groupBy(r, r.fund).map(g, + {"fund": g.key, "amount": g.rows.map(r, r.amount).sum()}) +``` + +`groupBy(row, key)` returns groups with `key` and `rows`, preserving first occurrence order. Keys must be strings, integers, unsigned integers or booleans. Null and error keys fail evaluation. + +`distinctBy(row, key)` returns the first row for each key. `latestBy(row, key)` returns the row with the greatest comparable key, such as a timestamp. It rejects empty input and tied maximum keys. `one()` requires exactly one row. + +```cel +funds.groupBy(r, r.fund).map(g, g.rows.latestBy(r, r.created)) +``` + +`left.joinBy(right, leftRow, leftKey, rightRow, rightKey)` is an inner join. It returns `{left, right}` pairs for every matching key, preserving left order and then right order. Unmatched rows are omitted; duplicate keys produce all matching pairs. Key rules match `groupBy`. + +```cel +before.joinBy(after, b, b.fund, a, a.fund) +``` + +For a list of plain decimal strings, `sum()` uses exact decimal arithmetic and returns a decimal string. For example, `["0.1", "0.2"].sum()` returns `"0.3"`. Invalid decimal strings fail evaluation. Integer and floating point lists keep their existing overloads. + +The Go API supports `Template{Strict: true}` for expressions that must fail on missing values. Strict and normal expression caches are separate. `CELValue` converts CEL lists and maps to native Go values while preserving integer precision and native host objects. From 4fe1c833536fcedfaa8a783719227e2a0be9cab0 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Wed, 4 Nov 2026 16:31:36 +0200 Subject: [PATCH 6/9] fix(serialize): Avoid mutating caller's map when serializing Alter rewrote the caller's map in place, so a *time.Time in an environment reused across expressions came back as a map. Decompose works on copies, so callers keep their original values. --- serialize.go | 4 +++- tests/serialize_test.go | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/serialize.go b/serialize.go index 3541a0a6e..8f452aa46 100644 --- a/serialize.go +++ b/serialize.go @@ -72,7 +72,9 @@ func Serialize(in map[string]any) (out map[string]any, err error) { } }) - out = alt.Alter(in, &opts).(map[string]any) + // Decompose copies: Alter would rewrite the caller's map in place, so a value such as a *time.Time in an + // environment the caller reuses would come back as a map after one expression. + out = alt.Decompose(in, &opts).(map[string]any) for _, native := range nativeTypes { if err := native.path.SetOne(out, native.val); err != nil { diff --git a/tests/serialize_test.go b/tests/serialize_test.go index 59742ab53..1c58a054e 100644 --- a/tests/serialize_test.go +++ b/tests/serialize_test.go @@ -295,3 +295,25 @@ func Test_serialize(t *testing.T) { }) } } + +// A caller evaluates several expressions over one environment, so serializing it must leave the caller's values +// as they were: a *time.Time nested in a map is still a *time.Time afterwards. +func Test_serializeLeavesInputUnchanged(t *testing.T) { + t.Parallel() + created := time.Date(2026, time.November, 4, 11, 8, 19, 0, time.UTC) + step := map[string]any{"created": &created, "rows": []any{map[string]any{"due": "01/09/2026"}}} + in := map[string]any{"steps": map[string]any{"invoicestart": step}} + + if _, err := gomplate.Serialize(in); err != nil { + t.Fatalf("serialize() error = %v", err) + } + if got, ok := step["created"].(*time.Time); !ok || !got.Equal(created) { + t.Fatalf("serialize() changed the input's created value to %T %v", step["created"], step["created"]) + } + if _, err := gomplate.RunExpression(in, gomplate.Template{Expression: `steps.invoicestart.rows[0].due == "01/09/2026"`}); err != nil { + t.Fatalf("RunExpression() error = %v", err) + } + if _, ok := step["created"].(*time.Time); !ok { + t.Fatalf("RunExpression() changed the input's created value to %T", step["created"]) + } +} From ccd2f29f6403938d343df755e7425884b6d5c404 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Wed, 4 Nov 2026 16:31:46 +0200 Subject: [PATCH 7/9] feat(cel): add strict CEL mode and exact decimal sum function Add an opt-in Strict template flag that compiles CEL without the nilsafe library, which now applies only to non-strict envs. Variables are declared as DynType instead of AnyType. Results are unwrapped by CELValue, which no longer round-trips through JSON, so integral floats stay as float64 and whole numbers are no longer narrowed. Adds a sum() overload that adds decimal strings exactly using big.Rat. BREAKING CHANGE: celResultValue is replaced by the exported CELValue, and numeric result types from expressions may change. --- cel.go | 19 ++++++++-- cel_decimal.go | 42 ++++++++++++++++++++++ cel_expression.go | 15 +++++--- cel_result.go | 89 +++++++++++++---------------------------------- template.go | 1 + 5 files changed, 95 insertions(+), 71 deletions(-) create mode 100644 cel_decimal.go diff --git a/cel.go b/cel.go index c85e18db8..951486fa1 100644 --- a/cel.go +++ b/cel.go @@ -39,11 +39,12 @@ func staticCelEnvOptions() []cel.EnvOption { opts = append(opts, ext.Strings(), ext.Encoders(), ext.Lists(), ext.Math(), ext.Sets()) opts = append(opts, cel.StdLib()) opts = append(opts, cel.OptionalTypes(cel.OptionalTypesVersion(1))) - opts = append(opts, nilsafe.Library(nilsafe.WithZeroValues())) opts = append(opts, strings.Library...) opts = append(opts, getGoTemplateCelFunction()) opts = append(opts, getDebugCelFunction()) opts = append(opts, getFoldCelLibrary()) + opts = append(opts, collectionCelOptions()...) + opts = append(opts, decimalSumOption()) opts = append(opts, cel.RegexProgramSizeLimit(celRegexProgramSizeLimit)) return opts } @@ -61,12 +62,20 @@ func staticCelEnvOptions() []cel.EnvOption { // // Env.Extend uses copy-on-write and never mutates the receiver, so the cached // base env is safe to share across goroutines. -var baseCelEnv = sync.OnceValues(func() (*cel.Env, error) { +var strictBaseCelEnv = sync.OnceValues(func() (*cel.Env, error) { opts := staticCelEnvOptions() opts = append(opts, cel.EagerlyValidateDeclarations(true)) return cel.NewEnv(opts...) }) +var baseCelEnv = sync.OnceValues(func() (*cel.Env, error) { + base, err := strictBaseCelEnv() + if err != nil { + return nil, err + } + return base.Extend(nilsafe.Library(nilsafe.WithZeroValues())) +}) + // GetCelEnv returns the full set of CEL env options: the static options, any // types registered via RegisterType, and one variable per environment key. // @@ -76,13 +85,14 @@ var baseCelEnv = sync.OnceValues(func() (*cel.Env, error) { // option set via staticCelEnvOptions. func GetCelEnv(environment map[string]any) []cel.EnvOption { opts := staticCelEnvOptions() + opts = append(opts, nilsafe.Library(nilsafe.WithZeroValues())) if nativeTypes := currentNativeTypes(); nativeTypes.envOption != nil { opts = append(opts, nativeTypes.envOption) } // Load input as variables for k := range environment { - opts = append(opts, cel.Variable(k, cel.AnyType)) + opts = append(opts, cel.Variable(k, cel.DynType)) } return opts @@ -98,6 +108,9 @@ func GetCelEnv(environment map[string]any) []cel.EnvOption { // template's own Functions and CelEnvs as undeclared references. func CompileEnvOptions(environment map[string]any, template Template) []cel.EnvOption { opts := staticCelEnvOptions() + if !template.Strict { + opts = append(opts, nilsafe.Library(nilsafe.WithZeroValues())) + } return append(opts, celEnvOptions(environment, template, currentNativeTypes())...) } diff --git a/cel_decimal.go b/cel_decimal.go new file mode 100644 index 000000000..91c4793a5 --- /dev/null +++ b/cel_decimal.go @@ -0,0 +1,42 @@ +package gomplate + +import ( + "math/big" + "regexp" + "strings" + + "github.com/google/cel-go/cel" + "github.com/google/cel-go/common/types" + "github.com/google/cel-go/common/types/ref" + "github.com/google/cel-go/common/types/traits" +) + +var plainDecimal = regexp.MustCompile(`^[+-]?[0-9]+(\.[0-9]+)?$`) + +func decimalSumOption() cel.EnvOption { + return cel.Function("sum", cel.MemberOverload("collection_sum_decimal_strings", []*cel.Type{cel.ListType(cel.StringType)}, cel.StringType, cel.UnaryBinding(sumDecimalStrings))) +} + +func sumDecimalStrings(value ref.Val) ref.Val { + total, scale := new(big.Rat), 0 + for iterator := value.(traits.Lister).Iterator(); iterator.HasNext() == types.True; { + item := iterator.Next() + text, ok := item.(types.String) + if !ok || !plainDecimal.MatchString(string(text)) { + return types.NewErr("sum requires decimal strings, got %v", item) + } + number, ok := new(big.Rat).SetString(string(text)) + if !ok { + return types.NewErr("sum requires decimal strings, got %q", text) + } + if point := strings.IndexByte(string(text), '.'); point >= 0 { + scale = max(scale, len(text)-point-1) + } + total.Add(total, number) + } + result := total.FloatString(scale) + if scale > 0 { + result = strings.TrimRight(strings.TrimRight(result, "0"), ".") + } + return types.String(result) +} diff --git a/cel_expression.go b/cel_expression.go index 3b9d1962b..0e853e01c 100644 --- a/cel_expression.go +++ b/cel_expression.go @@ -68,11 +68,14 @@ func RunExpressionContext(ctx commonsContext.Context, environment map[string]any if ctx.Logger != nil && out.Value() != template.Expression && properties.On(false, "gomplate.log") { ctx.Logger.V(4).Infof("templated %s => %v", template.ShortString(), out) } - return celResultValue(out), nil + return CELValue(out), nil } func compileCELProgram(data map[string]any, template Template, nativeTypes *nativeTypeSnapshot, trackState bool) (cel.Program, *cel.Ast, error) { base, err := baseCelEnv() + if template.Strict { + base, err = strictBaseCelEnv() + } if err != nil { return nil, nil, err } @@ -114,7 +117,7 @@ func celEnvOptions(data map[string]any, template Template, nativeTypes *nativeTy envOptions = append(envOptions, nativeTypes.envOption) } for key := range data { - envOptions = append(envOptions, cel.Variable(key, cel.AnyType)) + envOptions = append(envOptions, cel.Variable(key, cel.DynType)) } for name, function := range template.Functions { functionName := name @@ -137,8 +140,12 @@ func celEnvOptions(data map[string]any, template Template, nativeTypes *nativeTy } func (t Template) celCacheKey(environment map[string]any, nativeTypeGeneration uint64) string { + profile := "" + if t.Strict { + profile = "strict:" + } if nativeTypeGeneration == 0 { - return t.cacheKey(environment) + return profile + t.cacheKey(environment) } - return strconv.FormatUint(nativeTypeGeneration, 10) + ":" + t.cacheKey(environment) + return profile + strconv.FormatUint(nativeTypeGeneration, 10) + ":" + t.cacheKey(environment) } diff --git a/cel_result.go b/cel_result.go index 9c29e22fe..620081781 100644 --- a/cel_result.go +++ b/cel_result.go @@ -1,59 +1,47 @@ package gomplate import ( - "math" "reflect" "github.com/google/cel-go/common/types" "github.com/google/cel-go/common/types/ref" "github.com/google/cel-go/common/types/traits" - "google.golang.org/protobuf/types/known/structpb" ) -// celResultValue is what an expression hands back to its caller. -// -// A map or list *constructed* inside CEL is held in cel-go's own representation, -// and its Value() is a map[ref.Val]ref.Val -- which marshals to {"Adapter":{}} -// and is useless to a caller that wants to write the result somewhere. A value -// merely *selected* out of the environment was never converted in the first -// place and comes back as whatever Go type it went in as, which is why the -// conversion is applied to aggregates rather than to everything: a registered -// native type must still round-trip as its own struct, not as a map. -func celResultValue(out ref.Val) any { - // A CEL null's Value() is structpb.NullValue, a protobuf enum whose value is - // 0. A caller checking for nil sees a number, and a caller writing the result - // records a zero where the expression said nothing at all. +// CELValue unwraps CEL-created aggregates without converting numbers through +// JSON. Host-provided native objects retain their original type. +func CELValue(out ref.Val) any { if out == types.NullValue { return nil } - switch out.(type) { - case traits.Mapper, traits.Lister: - default: - return out.Value() - } - // An aggregate is not automatically cel-go's own: a function returning - // []string, or a map read straight out of the environment, is wrapped in a - // Lister or Mapper while its Value() stays the Go value it always was. - // Converting those would widen `"open-source".split("-")` from []string to - // []any for no gain, so only the representations holding ref.Vals are - // rewritten. if !holdsCELValues(out.Value()) { return out.Value() } - - native, err := out.ConvertToNative(types.JSONValueType) - if err != nil { - // Prior behaviour, deliberately. A value CEL cannot render as JSON is one - // this function has nothing better to say about, and returning what the - // caller used to get is strictly no worse than failing the evaluation. - return out.Value() - } - json, ok := native.(*structpb.Value) - if !ok { + switch value := out.(type) { + case traits.Lister: + rows := make([]any, 0, int(value.Size().(types.Int))) + for iterator := value.Iterator(); iterator.HasNext() == types.True; { + rows = append(rows, CELValue(iterator.Next())) + } + return rows + case traits.Mapper: + strings, keys := map[string]any{}, map[any]any{} + for iterator := value.Iterator(); iterator.HasNext() == types.True; { + key := iterator.Next() + nativeKey, nativeValue := CELValue(key), CELValue(value.Get(key)) + keys[nativeKey] = nativeValue + if name, ok := nativeKey.(string); ok { + strings[name] = nativeValue + } + } + if len(strings) == len(keys) { + return strings + } + return keys + default: return out.Value() } - return celWholeNumbers(json.AsInterface()) } var refValType = reflect.TypeOf((*ref.Val)(nil)).Elem() @@ -74,30 +62,3 @@ func holdsCELValues(value any) bool { return true } } - -// celWholeNumbers narrows integral floats back to integers. structpb's only -// numeric type is a float64, so a count of two would otherwise come back as -// `2.0` and a schema declaring that field an integer would be describing -// something the value does not look like. A genuinely fractional value -- a -// score of 6.9 -- is left alone. -func celWholeNumbers(value any) any { - switch typed := value.(type) { - case float64: - if typed == math.Trunc(typed) && !math.IsInf(typed, 0) && math.Abs(typed) < 1<<53 { - return int64(typed) - } - return typed - case []any: - for index, item := range typed { - typed[index] = celWholeNumbers(item) - } - return typed - case map[string]any: - for key, item := range typed { - typed[key] = celWholeNumbers(item) - } - return typed - default: - return value - } -} diff --git a/template.go b/template.go index 5c1010291..db55a5d18 100644 --- a/template.go +++ b/template.go @@ -56,6 +56,7 @@ type Template struct { // zero-arg function in addition to dot-access. Enables {{ foo }} alongside // the standard {{ .foo }}. ValueFunctions bool `yaml:"-" json:"-"` + Strict bool `yaml:"-" json:"-"` // Pass in additional cel-env options like functions // that aren't simple enough to be included in Functions From fd87d7ce9d74a3d73b8e98f0c8488fc842624c33 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Tue, 10 Nov 2026 09:12:20 +0200 Subject: [PATCH 8/9] fix: prevent cache key collisions and bound CEL collection and decimal work Cache keys now length-prefix every field, including the caller label and environment variable names, so templates with different bodies or env shapes can no longer share an entry; go templates keep their eligibility rules but the label participates in the key. joinBy rejects results beyond 10,000 pairs and decimal sum rejects individual strings over 4,096 bytes before exact parsing, so hostile inputs cannot exhaust memory or CPU. CELValue returns the CEL map when a key has no hashable native form instead of panicking on conversion. The playground evaluator now binds its own listener, falls back to an OS-assigned port, and exits when stdin closes, letting Vite proxy the actual bound port. Gavel-Issue-Id: ce5657c0-f456-4c24-b60a-b8a0e3f0de49 Claude-Session-Id: 01a1c440-0e0c-7622-bfb5-253106d09675 --- .github/workflows/web.yml | 2 +- cache_keys_spec_test.go | 62 +++++++++ cel_collections.go | 18 ++- cel_collections_spec_test.go | 31 +++++ cel_decimal.go | 6 + cel_expression.go | 15 +- cel_result.go | 4 + cel_result_keys_test.go | 35 +++++ cmd/playground/listen.go | 20 +++ cmd/playground/listen_test.go | 42 ++++++ cmd/playground/main.go | 20 ++- docs/content/cel-collections.md | 2 + template.go | 43 +++--- web/apps/playground/plugins/eval-server.ts | 143 ++++++++------------ web/apps/playground/test/evalServer.test.ts | 99 ++++++++++++++ web/apps/playground/test/viteConfig.test.ts | 24 ---- web/apps/playground/vite.config.ts | 15 +- 17 files changed, 432 insertions(+), 149 deletions(-) create mode 100644 cache_keys_spec_test.go create mode 100644 cel_result_keys_test.go create mode 100644 cmd/playground/listen.go create mode 100644 cmd/playground/listen_test.go create mode 100644 web/apps/playground/test/evalServer.test.ts diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml index 0a175c8a2..6982b2f02 100644 --- a/.github/workflows/web.yml +++ b/.github/workflows/web.yml @@ -35,7 +35,7 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0 with: - version: 10 + version: 11.22.0 - name: Install Node uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3 with: diff --git a/cache_keys_spec_test.go b/cache_keys_spec_test.go new file mode 100644 index 000000000..cd945f301 --- /dev/null +++ b/cache_keys_spec_test.go @@ -0,0 +1,62 @@ +package gomplate + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("cache key boundaries", func() { + It("keeps delimiter and body boundaries distinct on cache hits", func() { + for _, label := range []string{"", "explicit-delimiter-key"} { + for _, template := range []Template{ + {LeftDelim: "$(", RightDelim: ")", Template: "Hi", CacheKey: label}, + {LeftDelim: "$", RightDelim: ")", Template: "(Hi", CacheKey: label}, + } { + for range 2 { + value, err := RunTemplate(nil, template) + Expect(err).NotTo(HaveOccurred()) + Expect(value).To(Equal(template.Template)) + } + } + } + }) + + It("keeps explicit Go template cache labels separate", func() { + first := Template{Template: "hello", CacheKey: "first"} + second := first + second.CacheKey = "second" + Expect(first.autoCacheKey(nil)).NotTo(Equal(second.autoCacheKey(nil))) + }) + + It("keeps environment variable boundaries distinct", func() { + template := Template{Expression: "1"} + Expect(template.autoCacheKey(map[string]any{"a-b": 1})). + NotTo(Equal(template.autoCacheKey(map[string]any{"a": 1, "b": 1}))) + }) + + It("keeps strict semantics separate from explicit key prefixes", func() { + for _, strictFirst := range []bool{false, true} { + celExpressionCache.Flush() + for _, strict := range []bool{strictFirst, !strictFirst, strictFirst} { + key := "strict:boundary" + if strict { + key = "boundary" + } + value, err := RunExpression(map[string]any{"row": map[string]any{}}, Template{ + Expression: "row.missing == 0", Strict: strict, CacheKey: key, + }) + if strict { + Expect(err).To(HaveOccurred()) + } else { + Expect(err).NotTo(HaveOccurred()) + Expect(value).To(BeTrue()) + } + } + } + }) + + It("keeps native generations separate from explicit key prefixes", func() { + Expect((Template{CacheKey: "1:boundary"}).celCacheKey(nil, 0)). + NotTo(Equal((Template{CacheKey: "boundary"}).celCacheKey(nil, 1))) + }) +}) diff --git a/cel_collections.go b/cel_collections.go index 6f5d19e11..6f3ee4681 100644 --- a/cel_collections.go +++ b/cel_collections.go @@ -11,8 +11,10 @@ import ( "github.com/google/cel-go/common/types/traits" ) +// collectionCelOptions registers collection macros and their runtime bindings. func collectionCelOptions() []cel.EnvOption { - options := []cel.EnvOption{cel.Macros(cel.ReceiverMacro("joinBy", 5, joinByMacro))} + options := make([]cel.EnvOption, 0, 9) + options = append(options, cel.Macros(cel.ReceiverMacro("joinBy", 5, joinByMacro))) for name, binding := range map[string]func(ref.Val) ref.Val{ "groupBy": groupRows, "distinctBy": distinctRows, "latestBy": latestRow, } { @@ -27,6 +29,7 @@ func collectionCelOptions() []cel.EnvOption { ) } +// collectionKeyMacro projects rows with their keys before calling a collection binding. func collectionKeyMacro(name string) cel.MacroFactory { return func(factory cel.MacroExprFactory, target ast.Expr, args []ast.Expr) (ast.Expr, *cel.Error) { projection, err := keyedRowsMacro(factory, target, args) @@ -37,6 +40,7 @@ func collectionKeyMacro(name string) cel.MacroFactory { } } +// keyedRowsMacro builds row/key pairs while evaluating each key expression once. func keyedRowsMacro(factory cel.MacroExprFactory, target ast.Expr, args []ast.Expr) (ast.Expr, *cel.Error) { variable, err := extractFoldIdent(factory, args[0]) if err != nil { @@ -49,6 +53,7 @@ func keyedRowsMacro(factory cel.MacroExprFactory, target ast.Expr, args []ast.Ex factory.NewIdent(accumulator)), nil } +// joinByMacro projects both inputs into the keyed rows consumed by joinRows. func joinByMacro(factory cel.MacroExprFactory, target ast.Expr, args []ast.Expr) (ast.Expr, *cel.Error) { left, err := keyedRowsMacro(factory, target, args[1:3]) if err != nil { @@ -66,6 +71,7 @@ type collectionGroup struct { rows []ref.Val } +// indexedGroups validates scalar keys and indexes groups in first occurrence order. func indexedGroups(value ref.Val) ([]collectionGroup, map[any]int, ref.Val) { list, ok := value.(traits.Lister) if !ok { @@ -95,6 +101,7 @@ func indexedGroups(value ref.Val) ([]collectionGroup, map[any]int, ref.Val) { return groups, index, nil } +// groupRows returns key/rows maps without converting native row values. func groupRows(value ref.Val) ref.Val { groups, _, err := indexedGroups(value) if err != nil { @@ -109,6 +116,7 @@ func groupRows(value ref.Val) ref.Val { return types.NewRefValList(types.DefaultTypeAdapter, rows) } +// distinctRows returns the first row in each group, preserving group order. func distinctRows(value ref.Val) ref.Val { groups, _, err := indexedGroups(value) if err != nil { @@ -121,6 +129,7 @@ func distinctRows(value ref.Val) ref.Val { return types.NewRefValList(types.DefaultTypeAdapter, rows) } +// oneRow rejects empty or ambiguous collections instead of selecting arbitrarily. func oneRow(value ref.Val) ref.Val { list := value.(traits.Lister) if list.Size() != types.Int(1) { @@ -129,6 +138,7 @@ func oneRow(value ref.Val) ref.Val { return list.Get(types.IntZero) } +// latestRow selects the greatest comparable key and rejects ties at that maximum. func latestRow(value ref.Val) ref.Val { list := value.(traits.Lister) if list.Size() == types.IntZero { @@ -163,6 +173,9 @@ func latestRow(value ref.Val) ref.Val { return latest } +const celJoinPairLimit = 10_000 + +// joinRows materializes matching pairs in input order, bounded by celJoinPairLimit. func joinRows(left, right ref.Val) ref.Val { _, _, err := indexedGroups(left) if err != nil { @@ -180,6 +193,9 @@ func joinRows(left, right ref.Val) ref.Val { continue } for _, rightRow := range rightGroups[position].rows { + if len(rows) >= celJoinPairLimit { + return types.NewErr("joinBy exceeds limit of %d result pairs", celJoinPairLimit) + } rows = append(rows, types.NewRefValMap(types.DefaultTypeAdapter, map[ref.Val]ref.Val{types.String("left"): pair.Get(types.IntZero), types.String("right"): rightRow})) } } diff --git a/cel_collections_spec_test.go b/cel_collections_spec_test.go index 0767b9db7..361b729f3 100644 --- a/cel_collections_spec_test.go +++ b/cel_collections_spec_test.go @@ -1,6 +1,8 @@ package gomplate import ( + "strings" + . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -39,11 +41,40 @@ var _ = Describe("verification collection functions", func() { Entry("typed keys", `[{'key':1}, {'key':'1'}].groupBy(r,r.key).size() == 2`), Entry("first distinct", `[{'key':'a','value':1}, {'key':'a','value':2}, {'key':'b','value':3}].distinctBy(r,r.key).map(r,r.value) == [1,3]`), Entry("latest row", `[{'date':2,'value':'new'}, {'date':1,'value':'old'}].latestBy(r,r.date).value == 'new'`), + Entry("latest after tie", `[{'date':1}, {'date':1}, {'date':2,'value':'new'}].latestBy(r,r.date).value == 'new'`), Entry("one row", `[{'amount':12}].one().amount == 12`), Entry("exact decimal sum", `['9007199254740993.01','0.09'].sum() == '9007199254740993.1'`), Entry("decimal cancellation", `['1.20','-1.20'].sum() == '0'`), Entry("join", `[{'fund':'a','amount':3}].joinBy([{'id':'a','amount':7}, {'id':'b','amount':4}], l,l.fund,r,r.id).one().right.amount == 7`), + Entry("join duplicate keys", `[{'fund':'a','amount':3}].joinBy([{'id':'a','amount':7}, {'id':'a','amount':8}], l,l.fund,r,r.id).map(p,p.right.amount) == [7,8]`), ) + It("allows the join pair limit and rejects the next matching pair", func() { + const pairLimit = 10_000 + for _, count := range []int{pairLimit, pairLimit + 1} { + value, err := RunExpression(map[string]any{"left": []int{1}, "right": make([]int, count)}, Template{ + Expression: `left.joinBy(right,l,1,r,1).size()`, Strict: true, + }) + if count > pairLimit { + Expect(err).To(MatchError(ContainSubstring("joinBy exceeds limit of 10000 result pairs"))) + } else { + Expect(err).NotTo(HaveOccurred()) + Expect(value).To(Equal(int64(pairLimit))) + } + } + }) + It("bounds decimal string length before exact parsing", func() { + const decimalLengthLimit = 4096 + for _, length := range []int{decimalLengthLimit, decimalLengthLimit + 1} { + decimal := "0." + strings.Repeat("0", length-3) + "1" + value, err := RunExpression(map[string]any{"amounts": []string{decimal}}, Template{Expression: "amounts.sum()"}) + if length > decimalLengthLimit { + Expect(err).To(MatchError(ContainSubstring("sum decimal string exceeds limit of 4096 bytes"))) + } else { + Expect(err).NotTo(HaveOccurred()) + Expect(value).To(Equal(decimal)) + } + } + }) DescribeTable("rejects ambiguous or invalid evidence", func(expression string, message string) { _, err := RunExpression(nil, Template{Expression: expression}) diff --git a/cel_decimal.go b/cel_decimal.go index 91c4793a5..3a8c05338 100644 --- a/cel_decimal.go +++ b/cel_decimal.go @@ -13,15 +13,21 @@ import ( var plainDecimal = regexp.MustCompile(`^[+-]?[0-9]+(\.[0-9]+)?$`) +const celDecimalStringLengthLimit = 4096 + func decimalSumOption() cel.EnvOption { return cel.Function("sum", cel.MemberOverload("collection_sum_decimal_strings", []*cel.Type{cel.ListType(cel.StringType)}, cel.StringType, cel.UnaryBinding(sumDecimalStrings))) } +// sumDecimalStrings adds bounded plain decimal strings without rounding. func sumDecimalStrings(value ref.Val) ref.Val { total, scale := new(big.Rat), 0 for iterator := value.(traits.Lister).Iterator(); iterator.HasNext() == types.True; { item := iterator.Next() text, ok := item.(types.String) + if ok && len(text) > celDecimalStringLengthLimit { + return types.NewErr("sum decimal string exceeds limit of %d bytes", celDecimalStringLengthLimit) + } if !ok || !plainDecimal.MatchString(string(text)) { return types.NewErr("sum requires decimal strings, got %v", item) } diff --git a/cel_expression.go b/cel_expression.go index 0e853e01c..91273a273 100644 --- a/cel_expression.go +++ b/cel_expression.go @@ -17,10 +17,12 @@ import ( var celExpressionCache = cache.New(time.Hour, time.Hour) +// RunExpression evaluates a CEL template and returns native result values. func RunExpression(environment map[string]any, template Template) (any, error) { return RunExpressionContext(newContext(), environment, template) } +// RunExpressionContext reuses eligible programs and records evaluation state when requested. func RunExpressionContext(ctx commonsContext.Context, environment map[string]any, template Template) (any, error) { tracker := celTrackerFromContext(ctx) if tracker != nil { @@ -71,6 +73,7 @@ func RunExpressionContext(ctx commonsContext.Context, environment map[string]any return CELValue(out), nil } +// compileCELProgram applies strictness, native types and optional state tracking to compilation. func compileCELProgram(data map[string]any, template Template, nativeTypes *nativeTypeSnapshot, trackState bool) (cel.Program, *cel.Ast, error) { base, err := baseCelEnv() if template.Strict { @@ -111,6 +114,7 @@ func compileCELProgram(data map[string]any, template Template, nativeTypes *nati return program, ast, nil } +// celEnvOptions declares input variables and adds caller functions and native types. func celEnvOptions(data map[string]any, template Template, nativeTypes *nativeTypeSnapshot) []cel.EnvOption { envOptions := make([]cel.EnvOption, 0, len(data)+len(template.Functions)+len(template.CelEnvs)+1) if nativeTypes.envOption != nil { @@ -139,13 +143,8 @@ func celEnvOptions(data map[string]any, template Template, nativeTypes *nativeTy return envOptions } +// celCacheKey separates strict semantics and native type generations from caller keys. func (t Template) celCacheKey(environment map[string]any, nativeTypeGeneration uint64) string { - profile := "" - if t.Strict { - profile = "strict:" - } - if nativeTypeGeneration == 0 { - return profile + t.cacheKey(environment) - } - return profile + strconv.FormatUint(nativeTypeGeneration, 10) + ":" + t.cacheKey(environment) + key := t.cacheKey(environment) + return strconv.FormatBool(t.Strict) + ":" + strconv.FormatUint(nativeTypeGeneration, 10) + ":" + strconv.Itoa(len(key)) + ":" + key } diff --git a/cel_result.go b/cel_result.go index 620081781..aed618f59 100644 --- a/cel_result.go +++ b/cel_result.go @@ -10,6 +10,7 @@ import ( // CELValue unwraps CEL-created aggregates without converting numbers through // JSON. Host-provided native objects retain their original type. +// Maps with keys that Go cannot hash retain their CEL representation. func CELValue(out ref.Val) any { if out == types.NullValue { return nil @@ -30,6 +31,9 @@ func CELValue(out ref.Val) any { for iterator := value.Iterator(); iterator.HasNext() == types.True; { key := iterator.Next() nativeKey, nativeValue := CELValue(key), CELValue(value.Get(key)) + if nativeKey != nil && !reflect.ValueOf(nativeKey).Comparable() { + return out.Value() + } keys[nativeKey] = nativeValue if name, ok := nativeKey.(string); ok { strings[name] = nativeValue diff --git a/cel_result_keys_test.go b/cel_result_keys_test.go new file mode 100644 index 000000000..0cfe7c797 --- /dev/null +++ b/cel_result_keys_test.go @@ -0,0 +1,35 @@ +package gomplate + +import ( + "github.com/google/cel-go/common/types" + "github.com/google/cel-go/common/types/ref" + "github.com/google/cel-go/common/types/traits" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type resultKeyMap struct { + traits.Mapper + key ref.Val +} + +func (m *resultKeyMap) Value() any { return m } +func (m *resultKeyMap) Iterator() traits.Iterator { + return types.NewRefValList(types.DefaultTypeAdapter, []ref.Val{m.key}).Iterator() +} +func (m *resultKeyMap) Get(ref.Val) ref.Val { return types.String("value") } + +var _ = Describe("CEL map result keys", func() { + DescribeTable("preserves maps whose native keys cannot be hashed", func(key ref.Val) { + value := &resultKeyMap{Mapper: types.NewStringInterfaceMap(types.DefaultTypeAdapter, nil), key: key} + Expect(func() { Expect(CELValue(value)).To(BeIdenticalTo(value)) }).NotTo(Panic()) + }, + Entry("bytes", types.Bytes("key")), + Entry("list", types.NewRefValList(types.DefaultTypeAdapter, []ref.Val{types.String("key")})), + Entry("map", types.NewRefValMap(types.DefaultTypeAdapter, map[ref.Val]ref.Val{types.String("key"): types.True})), + ) + It("converts comparable non-string keys", func() { + value := types.NewRefValMap(types.DefaultTypeAdapter, map[ref.Val]ref.Val{types.Int(3): types.String("value")}) + Expect(CELValue(value)).To(Equal(map[any]any{int64(3): "value"})) + }) +}) diff --git a/cmd/playground/listen.go b/cmd/playground/listen.go new file mode 100644 index 000000000..c83be5ee9 --- /dev/null +++ b/cmd/playground/listen.go @@ -0,0 +1,20 @@ +package main + +import ( + "errors" + "net" + "syscall" +) + +// listen retains the preferred port, or an OS-assigned port if it is occupied. +func listen(addr string, dynamic bool) (net.Listener, error) { + listener, err := net.Listen("tcp", addr) + if err == nil || !dynamic || !errors.Is(err, syscall.EADDRINUSE) { + return listener, err + } + host, _, err := net.SplitHostPort(addr) + if err != nil { + return nil, err + } + return net.Listen("tcp", net.JoinHostPort(host, "0")) +} diff --git a/cmd/playground/listen_test.go b/cmd/playground/listen_test.go new file mode 100644 index 000000000..366bcfb36 --- /dev/null +++ b/cmd/playground/listen_test.go @@ -0,0 +1,42 @@ +package main + +import ( + "net" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestPlaygroundListener(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Playground listener") +} + +var _ = Describe("evaluation listener allocation", func() { + It("retains the selected port while serving and uses another port on conflict", func() { + occupied, err := net.Listen("tcp", "127.0.0.1:0") + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(occupied.Close) + listener, err := listen(occupied.Addr().String(), true) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(listener.Close) + Expect(listener.Addr().String()).NotTo(Equal(occupied.Addr().String())) + competitor, err := net.Listen("tcp", listener.Addr().String()) + if competitor != nil { + competitor.Close() + } + Expect(err).To(HaveOccurred()) + }) + It("reports occupied ports when dynamic allocation is disabled", func() { + occupied, err := net.Listen("tcp", "127.0.0.1:0") + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(occupied.Close) + _, err = listen(occupied.Addr().String(), false) + Expect(err).To(HaveOccurred()) + }) + It("does not hide invalid address errors", func() { + _, err := listen("127.0.0.1:invalid", true) + Expect(err).To(HaveOccurred()) + }) +}) diff --git a/cmd/playground/main.go b/cmd/playground/main.go index 143c15f54..5eff6db29 100644 --- a/cmd/playground/main.go +++ b/cmd/playground/main.go @@ -7,8 +7,11 @@ package main import ( + "errors" "flag" "fmt" + "io" + "net" "net/http" "os" "time" @@ -18,6 +21,8 @@ import ( func main() { addr := flag.String("addr", ":8321", "address to listen on") + dynamic := flag.Bool("dynamic-port", false, "allocate another port if the requested port is occupied") + watchStdin := flag.Bool("watch-stdin", false, "stop when the parent closes standard input") timeout := flag.Duration("timeout", 5*time.Second, "ceiling on one evaluation") flag.Parse() @@ -35,8 +40,19 @@ func main() { ReadHeaderTimeout: 5 * time.Second, } - fmt.Printf("playground: listening on %s\n", *addr) - if err := server.ListenAndServe(); err != nil { + listener, err := listen(*addr, *dynamic) + if err != nil { + fmt.Fprintln(os.Stderr, "playground:", err) + os.Exit(1) + } + if *watchStdin { + go func() { + _, _ = io.Copy(io.Discard, os.Stdin) + _ = listener.Close() + }() + } + fmt.Printf("playground: listening on %s\n", listener.Addr()) + if err := server.Serve(listener); err != nil && !errors.Is(err, net.ErrClosed) && !errors.Is(err, http.ErrServerClosed) { fmt.Fprintln(os.Stderr, "playground:", err) os.Exit(1) } diff --git a/docs/content/cel-collections.md b/docs/content/cel-collections.md index 588de6ba7..1b522d28d 100644 --- a/docs/content/cel-collections.md +++ b/docs/content/cel-collections.md @@ -21,6 +21,8 @@ funds.groupBy(r, r.fund).map(g, g.rows.latestBy(r, r.created)) `left.joinBy(right, leftRow, leftKey, rightRow, rightKey)` is an inner join. It returns `{left, right}` pairs for every matching key, preserving left order and then right order. Unmatched rows are omitted; duplicate keys produce all matching pairs. Key rules match `groupBy`. +Joins reject results exceeding 10,000 pairs. Decimal `sum()` rejects individual strings longer than 4,096 bytes before parsing them. + ```cel before.joinBy(after, b, b.fund, a, a.fund) ``` diff --git a/template.go b/template.go index db55a5d18..c36f782f7 100644 --- a/template.go +++ b/template.go @@ -74,8 +74,8 @@ type Template struct { // any two templates sharing this key may share the compiled cel.Program // (same expression semantics, same env shape, same function semantics). // - // NOTE: CacheKey applies to cel expressions only. The go template path ignores - // it and always derives its key from the template body and delimiters. A parsed + // The go template path includes this label alongside the body and delimiters. + // It cannot override go template cache eligibility. A parsed // go template keeps the Functions it was parsed with, and the caller cannot // assert those are interchangeable across calls; see isGoTemplateCacheable. CacheKey string `yaml:"-" json:"-"` @@ -130,8 +130,8 @@ func short(v string) string { return fmt.Sprintf("%s .. %d more lines", lines[0], len(lines)-1) } -// autoCacheKey derives a cache key from the template fields and the env shape. -// Used when the caller has not supplied an explicit CacheKey. +// autoCacheKey encodes template fields and environment variable names with length prefixes. +// Go templates always use it; CEL uses it when CacheKey is not supplied. func (t Template) autoCacheKey(env map[string]any) string { envVars := make([]string, 0, len(env)+1) for k := range env { @@ -139,13 +139,14 @@ func (t Template) autoCacheKey(env map[string]any) string { } sort.Slice(envVars, func(i, j int) bool { return envVars[i] < envVars[j] }) - return strings.Join(envVars, "-") + - t.RightDelim + - t.LeftDelim + - t.Expression + - t.Javascript + - t.JSONPath + - t.Template + fields := append([]string{t.CacheKey, t.RightDelim, t.LeftDelim, t.Expression, t.Javascript, t.JSONPath, t.Template}, envVars...) + var key strings.Builder + for _, field := range fields { + key.WriteString(strconv.Itoa(len(field))) + key.WriteByte(':') + key.WriteString(field) + } + return key.String() } // cacheKey returns the key to use for the program/template cache. If the caller @@ -181,8 +182,8 @@ func (t Template) IsCacheable() bool { // // A parsed go template retains the Functions that were bound when it was parsed, // so reusing one would serve an earlier call's function values. An explicit -// CacheKey cannot make that safe, and it also collapses each delimiter pass onto -// a single entry, so the go template path ignores CacheKey entirely. +// CacheKey cannot make that safe. Using it alone would also collapse delimiter +// passes onto a single entry, so keys always include the body and delimiters. func (t Template) isGoTemplateCacheable() bool { return len(t.CelEnvs) == 0 && len(t.Functions) == 0 } @@ -256,6 +257,7 @@ func RunTemplateContext(ctx commonsContext.Context, environment map[string]any, return "", nil } +// runGoTemplate binds value functions and renders each selected delimiter pass in order. func runGoTemplate(ctx commonsContext.Context, template Template, environment map[string]any) (string, error) { // Parse the gotemplate header once up-front so we can detect whether the // header set delimiters (which overrides DelimSets to a single pass). @@ -307,11 +309,17 @@ func runGoTemplate(ctx commonsContext.Context, template Template, environment ma return val, nil } +// goTemplate caches eligible parses by their full identity and executes with fresh input. func goTemplate(ctx commonsContext.Context, template Template, environment map[string]any) (string, error) { + template, err := parseAndStripTemplateHeader(template) + if err != nil { + return "", err + } + cacheKey := template.autoCacheKey(nil) var tpl *gotemplate.Template if template.isGoTemplateCacheable() { - cached, ok := goTemplateCache.Get(template.autoCacheKey(nil)) + cached, ok := goTemplateCache.Get(cacheKey) if ok { if cachedTpl, ok := cached.(*gotemplate.Template); ok { if ctx.Logger != nil && properties.On(false, "gomplate.log") { @@ -323,11 +331,6 @@ func goTemplate(ctx commonsContext.Context, template Template, environment map[s } if tpl == nil { - template, err := parseAndStripTemplateHeader(template) - if err != nil { - return "", err - } - tpl = gotemplate.New("") if template.LeftDelim != "" { tpl = tpl.Delims(template.LeftDelim, template.RightDelim) @@ -347,7 +350,7 @@ func goTemplate(ctx commonsContext.Context, template Template, environment map[s } if template.isGoTemplateCacheable() { - goTemplateCache.Set(template.autoCacheKey(nil), tpl, template.CacheTime) + goTemplateCache.Set(cacheKey, tpl, template.CacheTime) } } diff --git a/web/apps/playground/plugins/eval-server.ts b/web/apps/playground/plugins/eval-server.ts index d350ff70a..674edbc75 100644 --- a/web/apps/playground/plugins/eval-server.ts +++ b/web/apps/playground/plugins/eval-server.ts @@ -1,114 +1,91 @@ -import { spawn, type ChildProcess } from "node:child_process"; -import { createServer } from "node:net"; -import type { Plugin } from "vite"; +import { spawn } from "node:child_process"; +import { createInterface } from "node:readline"; +import type { Logger, Plugin } from "vite"; export interface EvalServerOptions { /** Repository root, where `go run` is invoked. */ repoRoot: string; /** Loopback host the Go server listens on. */ host: string; - /** Port the Go server listens on. */ + /** Preferred port; Go retains its listener or allocates another on conflict. */ port: number; } -interface AvailablePortOptions { - host: string; - preferredPort: number; -} - -export async function findAvailablePort({ - host, - preferredPort, -}: AvailablePortOptions): Promise { - const preferred = await tryPort(host, preferredPort); - if (preferred !== undefined) return preferred; - - const available = await tryPort(host, 0); - if (available === undefined) throw new Error("operating system did not allocate a loopback port"); - return available; -} - -function tryPort(host: string, port: number): Promise { +/** Starts Go and resolves only after it reports its retained listener address. */ +export function startEvalServer({ repoRoot, host, port, logger }: EvalServerOptions & { + logger: Pick; +}): Promise<{ port: number; stop: () => void }> { + const child = spawn("go", ["run", "./cmd/playground", "-addr", `${host}:${port}`, "-dynamic-port", "-watch-stdin"], { + cwd: repoRoot, + stdio: ["pipe", "pipe", "pipe"], + }); + const stop = () => { + // Closing stdin reaches the compiled grandchild as well as `go run`. + child.stdin.end(); + }; return new Promise((resolve, reject) => { - const server = createServer(); - server.unref(); - server.once("error", (error: NodeJS.ErrnoException) => { - if (error.code === "EADDRINUSE") resolve(undefined); - else reject(error); - }); - server.listen(port, host, () => { - const address = server.address(); - if (!address || typeof address === "string") { - server.close(); - reject(new Error(`could not resolve allocated loopback port for ${host}`)); + let ready = false; + const timeout = setTimeout(() => { + stop(); + reject(new Error("eval server did not bind within 120 seconds")); + }, 120_000); + const lines = createInterface({ input: child.stdout }); + lines.on("line", (line) => { + logger.info(`[gomplate] ${line}`); + const prefix = `playground: listening on ${host}:`; + if (!line.startsWith(prefix)) return; + const boundPort = Number(line.slice(prefix.length)); + if (!Number.isInteger(boundPort) || boundPort < 1 || boundPort > 65535) { + clearTimeout(timeout); + stop(); + reject(new Error(`invalid eval server bound address: ${line}`)); return; } - server.close((error) => (error ? reject(error) : resolve(address.port))); + ready = true; + clearTimeout(timeout); + resolve({ port: boundPort, stop }); + }); + child.stderr.on("data", (chunk: Buffer) => logger.error(`[gomplate] ${chunk.toString().trimEnd()}`)); + child.once("error", (error) => { + clearTimeout(timeout); + reject(error); + }); + child.once("exit", (code, signal) => { + clearTimeout(timeout); + lines.close(); + if (!ready) reject(new Error(`eval server exited before binding (code ${code}, signal ${signal})`)); + else if (code !== 0) logger.error(`[gomplate] eval server exited (code ${code}, signal ${signal})`); }); }); } -/** - * Runs `go run ./cmd/playground` alongside the dev server, so evaluation goes - * through the real gomplate engine rather than a reimplementation in the - * browser. - * - * Set `GOMPLATE_PLAYGROUND_SERVER=0` to manage the process yourself -- useful - * when attaching a debugger, or when iterating on Go code that would otherwise - * be recompiled on every Vite restart. +/** Runs the evaluator during development and points Vite at its bound port. + * Set GOMPLATE_PLAYGROUND_SERVER=0 to manage the evaluator yourself. */ -export function evalServer({ repoRoot, host, port }: EvalServerOptions): Plugin { - let child: ChildProcess | undefined; - +export function evalServer(options: EvalServerOptions): Plugin { + let stopChild: (() => void) | undefined; const stop = () => { - if (!child || child.killed) return; - child.kill("SIGTERM"); - child = undefined; + stopChild?.(); + stopChild = undefined; + for (const signal of ["SIGINT", "SIGTERM", "exit"] as const) process.off(signal, stop); }; - return { name: "gomplate-eval-server", apply: "serve", - - configureServer(server) { - // Vitest stands up a Vite server of its own; compiling and running the Go - // binary for a unit test would be minutes of nothing useful. + async configureServer(server) { if (process.env.VITEST) return; if (process.env.GOMPLATE_PLAYGROUND_SERVER === "0") { - server.config.logger.info( - `[gomplate] eval server not started; expecting one on :${port}`, - ); + server.config.logger.info(`[gomplate] eval server not started; expecting one on :${options.port}`); return; } - - child = spawn("go", ["run", "./cmd/playground", "-addr", `${host}:${port}`], { - cwd: repoRoot, - stdio: ["ignore", "pipe", "pipe"], - }); - - child.stdout?.on("data", (chunk: Buffer) => { - server.config.logger.info(`[gomplate] ${chunk.toString().trimEnd()}`); - }); - child.stderr?.on("data", (chunk: Buffer) => { - // `go run` reports compile errors here; they are the single most useful - // thing to surface, so do not swallow them. - server.config.logger.error(`[gomplate] ${chunk.toString().trimEnd()}`); - }); - child.on("exit", (code) => { - if (code !== 0 && code !== null) { - server.config.logger.error(`[gomplate] eval server exited with code ${code}`); - } - child = undefined; - }); - - // `go run` leaves the compiled binary as a grandchild, so kill on every - // way the dev server can end rather than relying on process-group death. - for (const signal of ["SIGINT", "SIGTERM", "exit"] as const) { - process.once(signal, stop); - } + const proxy = server.config.server.proxy?.["/api"]; + if (!proxy || typeof proxy === "string") throw new Error("eval server requires the /api proxy configuration"); + const running = await startEvalServer({ ...options, logger: server.config.logger }); + stopChild = running.stop; + proxy.target = `http://${options.host}:${running.port}`; + for (const signal of ["SIGINT", "SIGTERM", "exit"] as const) process.once(signal, stop); server.httpServer?.once("close", stop); }, - closeBundle: stop, }; } diff --git a/web/apps/playground/test/evalServer.test.ts b/web/apps/playground/test/evalServer.test.ts new file mode 100644 index 000000000..86188572b --- /dev/null +++ b/web/apps/playground/test/evalServer.test.ts @@ -0,0 +1,99 @@ +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ViteDevServer } from "vite"; + +vi.mock("node:child_process", () => ({ spawn: vi.fn() })); +import { spawn } from "node:child_process"; +import { evalServer, startEvalServer } from "../plugins/eval-server"; + +const logger = { info: vi.fn(), error: vi.fn() }; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + vi.useRealTimers(); +}); + +function childProcess() { + return Object.assign(new EventEmitter(), { + stdout: new PassThrough(), stderr: new PassThrough(), stdin: new PassThrough(), + }); +} + +describe("evaluation server startup", () => { + it("configures the proxy with the bound port and stops on Vite shutdown", async () => { + vi.stubEnv("VITEST", ""); + vi.stubEnv("GOMPLATE_PLAYGROUND_SERVER", "1"); + const child = childProcess(); + vi.mocked(spawn).mockReturnValue(child as unknown as ReturnType); + const proxy = { target: "http://127.0.0.1:8321" }; + const httpServer = new EventEmitter(); + const plugin = evalServer({ repoRoot: "/repo", host: "127.0.0.1", port: 8321 }); + if (typeof plugin.configureServer !== "function") throw new Error("expected a configureServer hook"); + const configured = plugin.configureServer.call({} as never, { + config: { logger, server: { proxy: { "/api": proxy } } }, httpServer, + } as unknown as ViteDevServer); + try { + child.stdout.write("playground: listening on 127.0.0.1:49153\n"); + await configured; + expect(proxy.target).toBe("http://127.0.0.1:49153"); + } finally { + httpServer.emit("close"); + } + expect(child.stdin.writableEnded).toBe(true); + child.emit("exit", 0); + }); + + it("waits for the bound address, including split stdout chunks", async () => { + const child = childProcess(); + vi.mocked(spawn).mockReturnValue(child as unknown as ReturnType); + const startup = startEvalServer({ repoRoot: "/repo", host: "127.0.0.1", port: 8321, logger }); + child.stdout.write("playground: listening on 127.0.0.1:"); + child.stdout.write("49153\n"); + const server = await startup; + expect(server.port).toBe(49153); + expect(spawn).toHaveBeenCalledWith("go", ["run", "./cmd/playground", "-addr", "127.0.0.1:8321", "-dynamic-port", "-watch-stdin"], expect.any(Object)); + server.stop(); + expect(child.stdin.writableEnded).toBe(true); + }); + + it("rejects startup when the process exits before binding", async () => { + const child = childProcess(); + vi.mocked(spawn).mockReturnValue(child as unknown as ReturnType); + const startup = startEvalServer({ repoRoot: "/repo", host: "127.0.0.1", port: 8321, logger }); + const rejected = expect(startup).rejects.toThrow("before binding"); + child.emit("exit", 1); + await rejected; + }); + + it("rejects spawn errors", async () => { + const child = childProcess(); + vi.mocked(spawn).mockReturnValue(child as unknown as ReturnType); + const startup = startEvalServer({ repoRoot: "/repo", host: "127.0.0.1", port: 8321, logger }); + const rejected = expect(startup).rejects.toThrow("spawn failed"); + child.emit("error", new Error("spawn failed")); + await rejected; + }); + + it("rejects invalid bound addresses and closes the child's input", async () => { + const child = childProcess(); + vi.mocked(spawn).mockReturnValue(child as unknown as ReturnType); + const startup = startEvalServer({ repoRoot: "/repo", host: "127.0.0.1", port: 8321, logger }); + const rejected = expect(startup).rejects.toThrow("invalid eval server bound address"); + child.stdout.write("playground: listening on 127.0.0.1:0\n"); + await rejected; + expect(child.stdin.writableEnded).toBe(true); + }); + + it("rejects a startup timeout and closes the child's input", async () => { + vi.useFakeTimers(); + const child = childProcess(); + vi.mocked(spawn).mockReturnValue(child as unknown as ReturnType); + const startup = startEvalServer({ repoRoot: "/repo", host: "127.0.0.1", port: 8321, logger }); + const rejected = expect(startup).rejects.toThrow("did not bind within 120 seconds"); + await vi.advanceTimersByTimeAsync(120_000); + await rejected; + expect(child.stdin.writableEnded).toBe(true); + }); +}); diff --git a/web/apps/playground/test/viteConfig.test.ts b/web/apps/playground/test/viteConfig.test.ts index 9b4dd809b..25fa6bbfc 100644 --- a/web/apps/playground/test/viteConfig.test.ts +++ b/web/apps/playground/test/viteConfig.test.ts @@ -1,7 +1,5 @@ -import { createServer } from "node:net"; import { describe, expect, it } from "vitest"; -import { findAvailablePort } from "../plugins/eval-server"; import { createPlaygroundConfig } from "../vite.config"; const SELECTED_EVAL_PORT = 49_153; @@ -39,26 +37,4 @@ describe("playground Vite configuration", () => { }); }); - it("selects another loopback port when the preferred port is occupied", async () => { - const occupied = createServer(); - await new Promise((resolve, reject) => { - occupied.once("error", reject); - occupied.listen(0, "127.0.0.1", resolve); - }); - - try { - const address = occupied.address(); - if (!address || typeof address === "string") throw new Error("test listener has no TCP port"); - const selectedPort = await findAvailablePort({ - host: "127.0.0.1", - preferredPort: address.port, - }); - - expect(selectedPort).not.toBe(address.port); - } finally { - await new Promise((resolve, reject) => { - occupied.close((error) => (error ? reject(error) : resolve())); - }); - } - }); }); diff --git a/web/apps/playground/vite.config.ts b/web/apps/playground/vite.config.ts index 4c9b9babf..b832c8c58 100644 --- a/web/apps/playground/vite.config.ts +++ b/web/apps/playground/vite.config.ts @@ -5,7 +5,7 @@ import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; import { defineConfig, type UserConfig } from "vite"; -import { evalServer, findAvailablePort } from "./plugins/eval-server"; +import { evalServer } from "./plugins/eval-server"; const root = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(root, "../../.."); @@ -45,6 +45,7 @@ interface PlaygroundConfigOptions { evalPort: number; } +/** Configures source aliases, dependency optimization and the evaluator proxy. */ export function createPlaygroundConfig({ command, mode, @@ -86,12 +87,6 @@ export function createPlaygroundConfig({ }; } -export default defineConfig(async ({ command, mode }) => { - const managesEvalServer = process.env.GOMPLATE_PLAYGROUND_SERVER !== "0"; - const evalPort = - command === "serve" && mode !== "test" && managesEvalServer - ? await findAvailablePort({ host: EVAL_HOST, preferredPort: EVAL_PORT }) - : EVAL_PORT; - - return createPlaygroundConfig({ command, mode, clickySourceAvailable, evalPort }); -}); +export default defineConfig(({ command, mode }) => + createPlaygroundConfig({ command, mode, clickySourceAvailable, evalPort: EVAL_PORT }), +); From 725757164a038bce47ae3b40f1532797447f52e3 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Tue, 10 Nov 2026 09:20:16 +0200 Subject: [PATCH 9/9] security(release): establish v3 security maintenance line with patched dependencies and scans Add a 3.x branch across CI, semantic-release, and Renovate so the v3 line receives only reviewed security fixes while main continues feature work. Bump Go to 1.26.10, x/net, x/text, x/tools, x/mod, x/sync, x/sys, and DOMPurify to clear known advisories without changing the v3 API or CEL import path. Add govulncheck and production web dependency scans plus a release verification script and SECURITY.md so version policy is enforced before merge instead of discovered at publish time. Refresh generated language definitions to match the current CEL macro and overload set. Gavel-Issue-Id: ce5657c0-f456-4c24-b60a-b8a0e3f0de49 Claude-Session-Id: 01a1c440-0e0c-7622-bfb5-253106d09675 --- .github/bench-v3.patch | 9 ++ .github/workflows/benchmark.yml | 18 ++-- .github/workflows/codeql-analysis.yml | 4 +- .github/workflows/lint.yml | 5 +- .github/workflows/release-policy.yml | 27 ++++++ .github/workflows/release.yml | 12 ++- .github/workflows/security.yml | 35 ++++++++ .github/workflows/test.yml | 7 +- .github/workflows/web.yml | 11 +-- .releaserc | 12 ++- SECURITY.md | 33 +++++++ go.mod | 14 +-- go.sum | 24 ++--- hack/verify-release.mjs | 89 +++++++++++++++++++ internal/releasepolicy/policy_test.go | 56 ++++++++++++ renovate.json | 44 ++++++++- .../lang/src/generated/languages.json | 5 ++ web/packages/lang/src/generated/spec.json | 42 +++++++++ web/pnpm-lock.yaml | 8 +- 19 files changed, 406 insertions(+), 49 deletions(-) create mode 100644 .github/bench-v3.patch create mode 100644 .github/workflows/release-policy.yml create mode 100644 .github/workflows/security.yml create mode 100644 SECURITY.md create mode 100644 hack/verify-release.mjs create mode 100644 internal/releasepolicy/policy_test.go diff --git a/.github/bench-v3.patch b/.github/bench-v3.patch new file mode 100644 index 000000000..4c4619a85 --- /dev/null +++ b/.github/bench-v3.patch @@ -0,0 +1,9 @@ +@@ +@@ +-"cel.dev/cel-go/cel" ++"github.com/google/cel-go/cel" + +@@ +@@ +-"cel.dev/cel-go/common/types/ref" ++"github.com/google/cel-go/common/types/ref" diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 596532368..3f425482c 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -14,15 +14,15 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 60 steps: - - name: Install Go - uses: buildjet/setup-go@555ce355a95ff01018ffcf8fbbd9c44654db8374 # v5.0.2 - with: - go-version: 1.26.x - cache: false - name: Checkout code uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: fetch-depth: 0 + - name: Install Go + uses: buildjet/setup-go@555ce355a95ff01018ffcf8fbbd9c44654db8374 # v5.0.2 + with: + go-version-file: go.mod + cache: false - name: Post "benchmark running" comment uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 with: @@ -78,12 +78,16 @@ jobs: # the same benchmark suite — only the implementation under test differs. cp serialize_bench_test.go .tmp/bench/base/serialize_bench_test.go cp run_expression_bench_test.go .tmp/bench/base/run_expression_bench_test.go + if rg -q '^module github.com/flanksource/gomplate/v3$' .tmp/bench/base/go.mod + then + go run github.com/uber-go/gopatch@v0.4.0 -p .github/bench-v3.patch .tmp/bench/base/run_expression_bench_test.go + fi - name: Compile benchmark binaries run: | cd .tmp/bench/base - go test -c -o "$GITHUB_WORKSPACE/.tmp/bench/base.test" github.com/flanksource/gomplate/v3 + go test -c -o "$GITHUB_WORKSPACE/.tmp/bench/base.test" . cd "$GITHUB_WORKSPACE" - go test -c -o .tmp/bench/head.test github.com/flanksource/gomplate/v3 + go test -c -o .tmp/bench/head.test . - name: Benchmark interleaved base and head run: | : > bench-base.txt diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 36b32dc07..e7dedb628 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -13,11 +13,11 @@ name: "CodeQL" on: push: - branches: [ main ] + branches: [ main, 3.x ] tags: [ 'v*' ] pull_request: # The branches below must be a subset of the branches above - branches: [ main ] + branches: [ main, 3.x ] schedule: - cron: '42 18 * * 0' diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 08465ffd8..b845b5584 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -3,6 +3,7 @@ on: push: branches: - main + - 3.x pull_request: permissions: @@ -13,10 +14,10 @@ jobs: name: lint runs-on: ubuntu-latest steps: + - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 - uses: buildjet/setup-go@555ce355a95ff01018ffcf8fbbd9c44654db8374 # v5.0.2 with: - go-version: 1.22.x - - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 + go-version-file: go.mod - name: golangci-lint uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0 with: diff --git a/.github/workflows/release-policy.yml b/.github/workflows/release-policy.yml new file mode 100644 index 000000000..1cf3f8ede --- /dev/null +++ b/.github/workflows/release-policy.yml @@ -0,0 +1,27 @@ +name: Release policy + +on: + pull_request: + branches: [main, 3.x] + push: + branches: [main, 3.x] + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + - uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3 + with: + node-version: 24 + - name: Install release verification dependencies + run: npm install --prefix .tmp/release-check --package-lock=false --no-audit --no-fund semantic-release@25.0.9 @semantic-release/commit-analyzer@13.0.1 yaml@2.8.1 semver@7.7.2 + - name: Verify candidate release and branch range + env: + RELEASE_BRANCH: ${{ github.base_ref || github.ref_name }} + run: node hack/verify-release.mjs "$RELEASE_BRANCH" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3a333c273..dddc357c5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,6 +3,7 @@ on: push: branches: - main + - 3.x permissions: contents: read @@ -18,8 +19,12 @@ jobs: egress-policy: audit - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 - uses: cycjimmy/semantic-release-action@b12c8f6015dc215fe37bc154d4ad456dd3833c90 # v6.0.0 id: semantic + with: + semantic_version: 25.0.9 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} outputs: @@ -29,7 +34,7 @@ jobs: bump-clients: runs-on: ubuntu-latest needs: [semantic-release] - if: needs.semantic-release.outputs.new-release-published == 'true' + if: needs.semantic-release.outputs.new-release-published == 'true' && startsWith(needs.semantic-release.outputs.release-version, '3.') strategy: matrix: repos: ["mission-control", "canary-checker", "commons", "config-db", "duty"] @@ -42,7 +47,7 @@ jobs: - name: Setup Go uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 # v5.0.0 with: - go-version: v1.22.x + go-version-file: go.mod - name: Create commits run: | @@ -51,7 +56,8 @@ jobs: go get github.com/flanksource/gomplate/v3@v${{ needs.semantic-release.outputs.release-version }} go mod tidy if [ -d "hack/generate-schemas" ]; then - cd hack/generate-schemas && go mod tidy + (cd hack/generate-schemas + go mod tidy) fi - name: Create Pull Request diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 000000000..4dd709774 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,35 @@ +name: Dependency security +on: + push: + branches: [main, 3.x] + pull_request: + schedule: + - cron: '0 6 * * *' + workflow_dispatch: +permissions: + contents: read +jobs: + scan: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + ref: ${{ (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && fromJSON('["main", "3.x"]') || fromJSON(format('[{0}]', toJSON(github.ref))) }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ matrix.ref }} + - uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 + with: + go-version-file: go.mod + - name: Scan Go dependencies + run: go run golang.org/x/vuln/cmd/govulncheck@v1.9.0 ./... + - uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d + with: + package_json_file: web/package.json + - uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b + with: + node-version: 24 + - run: pnpm -C web install --frozen-lockfile + - name: Scan production web dependencies + run: pnpm -C web audit --prod --audit-level low diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 844212094..a13c753bb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,6 +3,7 @@ on: push: branches: - main + - 3.x pull_request: permissions: @@ -12,11 +13,11 @@ jobs: test: runs-on: ubuntu-latest steps: + - name: Checkout code + uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2.7.0 - name: Install Go uses: buildjet/setup-go@555ce355a95ff01018ffcf8fbbd9c44654db8374 # v5.0.2 with: - go-version: 1.22.x - - name: Checkout code - uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2.7.0 + go-version-file: go.mod - name: Test run: go test ./... diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml index 6982b2f02..2b3c9f086 100644 --- a/.github/workflows/web.yml +++ b/.github/workflows/web.yml @@ -3,6 +3,7 @@ on: push: branches: - main + - 3.x pull_request: permissions: @@ -17,12 +18,12 @@ jobs: name: generated definitions are current runs-on: ubuntu-latest steps: + - name: Checkout code + uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2.7.0 - name: Install Go uses: buildjet/setup-go@555ce355a95ff01018ffcf8fbbd9c44654db8374 # v5.0.2 with: - go-version: 1.22.x - - name: Checkout code - uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2.7.0 + go-version-file: go.mod - name: Check for drift run: make monarch-check @@ -35,11 +36,11 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0 with: - version: 11.22.0 + package_json_file: web/package.json - name: Install Node uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3 with: - node-version: 22 + node-version: 24 cache: pnpm cache-dependency-path: web/pnpm-lock.yaml - name: Install dependencies diff --git a/.releaserc b/.releaserc index 347c0348d..017ad214c 100644 --- a/.releaserc +++ b/.releaserc @@ -1,8 +1,12 @@ branches: + - name: 3.x + range: 3.x + channel: 3.x - name: main plugins: - - "@semantic-release/commit-analyzer" - releaseRules: + - { breaking: true, release: major } - { type: doc, scope: README, release: patch } - { type: fix, release: patch } - { type: chore, release: patch } @@ -13,8 +17,12 @@ plugins: parserOpts: noteKeywords: - MAJOR RELEASE - - "@semantic-release/release-notes-generator" + - BREAKING CHANGE + - BREAKING CHANGES + - - "@semantic-release/release-notes-generator" + - parserOpts: + noteKeywords: ["MAJOR RELEASE", "BREAKING CHANGE", "BREAKING CHANGES"] - - "@semantic-release/github" # From: https://github.com/semantic-release/github/pull/487#issuecomment-1486298997 - successComment: false - failTitle: false + failTitle: false diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..96df45ff3 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,33 @@ +# Supported versions and security maintenance + +| Branch | Go module | CEL module | Support | +| --- | --- | --- | --- | +| main (before v4 publication) | github.com/flanksource/gomplate/v3 | github.com/google/cel-go | Active v3 publication | +| main (after v4 publication) | github.com/flanksource/gomplate/v4 | cel.dev/cel-go | Active development | +| 3.x | github.com/flanksource/gomplate/v3 | github.com/google/cel-go | Security fixes only | + +The v3 maintenance line starts at v3.24.90. There is no announced end-of-support date. Security patches preserve the v3 API, expression behavior, and CEL import path. Each patch must identify its advisory and use the lowest compatible fixed dependency version. Ordinary feature and dependency refreshes belong on main. Security fixes should be applied to every affected supported branch. + +Renovate processes both branches. Routine updates are disabled on 3.x, while vulnerability fixes require review. OSV checks direct dependencies; daily Go vulnerability and production web dependency scans supplement them, including transitive dependencies. A scheduled scan runs from main and explicitly checks out both supported branches. Failed scans require investigation; scanner errors are not passing results. + +Until main publishes v4.0.0, publish v3 security fixes from main and keep the 3.x branch synchronized with that published history. Semantic-release does not allow a maintenance branch to publish a version newer than the active main release line. After main publishes v4, v3.x patch releases are published from 3.x. Existing downstream v3 dependency-update PR automation runs only for v3 releases. Consumers must migrate explicitly to v4. + +## Migrating to v4 + +The initial security maintenance patch updates Go to 1.26.10 (including GO-2026-6809), x/net to 0.62.0 (GO-2026-6860 and GO-2026-6866), OpenTelemetry to 1.42.0 (GO-2026-5158), and their required dependency graph. This includes fixed x/text and x/mod versions. The web lockfile resolves DOMPurify 3.4.17, addressing the five advisories reported for 3.4.13. CEL remains at 0.31.0 on 3.x. + +Change Go imports from github.com/flanksource/gomplate/v3 to github.com/flanksource/gomplate/v4. When supplying CEL environment options or using AST, evaluation, or value types, change CEL imports from github.com/google/cel-go to cel.dev/cel-go. Do not mix the old and new CEL types. + +The initial v4 migration must use a Conventional Commit with a BREAKING CHANGE footer (or the existing MAJOR RELEASE footer) so semantic-release selects v4.0.0. The release analyzer and notes generator recognize both. Local preparation does not publish a release; publishing happens only after the reviewed migration is merged to main. + +## Verify release configuration without publishing + +Run from the repository root: + +```sh +npm install --prefix .tmp/release-check --package-lock=false --no-audit --no-fund semantic-release@25.0.9 @semantic-release/commit-analyzer@13.0.1 yaml@2.8.1 semver@7.7.2 +node hack/verify-release.mjs main +go test ./internal/releasepolicy +``` + +This checks the actual unreleased commits against the target branch's normalized semantic-release range and verifies that the candidate version matches the Go module major. Pass 3.x instead of main when validating a maintenance release. It also checks synthetic breaking and security-fix commits, workflow syntax, and the v3 downstream-update guard. The Release policy workflow runs this check before PRs are merged. It does not create commits, tags, releases, or external PRs. diff --git a/go.mod b/go.mod index e13591cfe..2becf77bd 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/flanksource/gomplate/v3 -go 1.26.1 +go 1.26.10 require ( github.com/Masterminds/goutils v1.1.1 @@ -28,8 +28,8 @@ require ( github.com/stretchr/testify v1.11.1 github.com/timberio/go-datemath v0.1.0 github.com/ugorji/go/codec v1.3.1 - golang.org/x/text v0.40.0 - golang.org/x/tools v0.47.0 + golang.org/x/text v0.43.0 + golang.org/x/tools v0.51.0 google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af gopkg.in/yaml.v3 v3.0.1 gotest.tools/v3 v3.5.1 @@ -89,10 +89,10 @@ require ( go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp v0.0.0-20260527015227-08cc5374adb3 // indirect - golang.org/x/mod v0.37.0 // indirect - golang.org/x/net v0.56.0 // indirect - golang.org/x/sync v0.22.0 // indirect - golang.org/x/sys v0.46.0 // indirect + golang.org/x/mod v0.41.0 // indirect + golang.org/x/net v0.62.0 // indirect + golang.org/x/sync v0.24.0 // indirect + golang.org/x/sys v0.49.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect gopkg.in/inf.v0 v0.9.1 // indirect diff --git a/go.sum b/go.sum index 39e811d91..ba86c13e6 100644 --- a/go.sum +++ b/go.sum @@ -206,8 +206,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= -golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= @@ -217,8 +217,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= -golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY= +golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -226,8 +226,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= -golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= +golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -239,8 +239,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -259,16 +259,16 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= -golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= +golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= -golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI= +golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 h1:tu/dtnW1o3wfaxCOjSLn5IRX4YDcJrtlpzYkhHhGaC4= google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng= diff --git a/hack/verify-release.mjs b/hack/verify-release.mjs new file mode 100644 index 000000000..5f6aef1c0 --- /dev/null +++ b/hack/verify-release.mjs @@ -0,0 +1,89 @@ +import assert from "node:assert/strict"; +import { readFileSync, readdirSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { createRequire } from "node:module"; +const require = createRequire(new URL("../.tmp/release-check/package.json", import.meta.url)); +const { parse } = require("yaml"); +const semver = require("semver"); +const { analyzeCommits } = await import(require.resolve("@semantic-release/commit-analyzer")); +const normalize = await import(require.resolve("semantic-release/lib/branches/normalize.js")); +const definitions = await import(require.resolve("semantic-release/lib/definitions/branches.js")); +const config = parse(readFileSync(".releaserc", "utf8")); +const options = config.plugins[0][1]; +const logger = { log() {} }; +const git = (...args) => execFileSync("git", args, { encoding: "utf8" }).trim(); +const target = process.argv[2] ?? "main"; +const revision = process.argv[3] ?? "HEAD"; +const branches = config.branches.map((branch) => { + const options = typeof branch === "string" ? { name: branch } : branch; + const tags = git("tag", "--merged", `origin/${options.name}`).split("\n") + .map((gitTag) => ({ gitTag, version: semver.valid(gitTag), channels: [null] })) + .filter(({ version }) => version && !semver.prerelease(version)); + return { ...options, tags }; +}); +function normalizeBranches(branches) { + const groups = { + maintenance: branches.filter(definitions.maintenance.filter), + release: branches.filter(definitions.release.filter), + }; + return [...normalize.maintenance(groups), ...normalize.release(groups)]; +} +const normalized = normalizeBranches(branches); +const fixtureTags = (versions) => versions.map((version) => ({ version, gitTag: `v${version}`, channels: [null] })); +for (const scenario of [ + { main: ["3.24.90"], maintenance: ["3.24.90"], target: "main", candidate: "3.24.91", allowed: true }, + { main: ["3.24.90"], maintenance: ["3.24.90"], target: "3.x", candidate: "3.24.91", allowed: false }, + { main: ["3.24.90"], maintenance: ["3.24.90"], target: "main", candidate: "4.0.0", allowed: true }, + { main: ["3.24.90", "3.24.91", "4.0.0"], maintenance: ["3.24.90", "3.24.91"], target: "3.x", candidate: "3.24.92", allowed: true }, +]) { + const fixtureBranches = branches.map((branch) => ({ ...branch, + tags: fixtureTags(branch.name === "main" ? scenario.main : scenario.maintenance), + })); + const branch = normalizeBranches(fixtureBranches).find(({ name }) => name === scenario.target); + assert.equal(semver.satisfies(scenario.candidate, branch.range), scenario.allowed, JSON.stringify(scenario)); +} +const branch = normalized.find(({ name }) => name === target); +assert(branch, `Unknown release branch: ${target}`); +const lastVersion = semver.rsort(branch.tags.map(({ version }) => version))[0]; +assert(lastVersion, `No published release found on ${target}`); +const lastTag = branch.tags.find(({ version }) => version === lastVersion).gitTag; +const commits = git("log", "--format=%B%x00", `${lastTag}..${revision}`).split("\0") + .map((message, index) => ({ hash: String(index), message: message.trim() })) + .filter(({ message }) => message); +const kind = await analyzeCommits(options, { commits, logger, cwd: process.cwd() }); +if (kind) { + const candidate = semver.inc(lastVersion, kind); + assert(semver.satisfies(candidate, branch.range), + `${target} cannot publish ${candidate}: allowed range is ${branch.range}. Publish v3 fixes from main until main publishes v4.`); + const moduleMajor = readFileSync("go.mod", "utf8").match(/^module github.com\/flanksource\/gomplate\/v(\d+)$/m); + assert(moduleMajor, "Cannot determine gomplate module major"); + assert.equal(semver.major(candidate), Number(moduleMajor[1]), "Release major must match the Go module"); + process.stdout.write(`${target} can publish ${candidate} (${branch.range})\n`); +} else { + process.stdout.write(`${target}: no unreleased conventional commits\n`); +} +for (const [message, expected] of [ + ["feat!: migrate gomplate and CEL module paths\n\nBREAKING CHANGE: use /v4 and cel.dev/cel-go imports", "major"], + ["fix(security): remediate dependency advisory", "patch"], +]) { + const kind = await analyzeCommits(options, { commits: [{hash:"example",message}], logger, cwd:process.cwd() }); + assert.equal(kind, expected); + assert.equal(semver.inc("3.24.90", kind), kind === "major" ? "4.0.0" : "3.24.91"); +} +for (const file of ["test.yml", "lint.yml", "web.yml", "release.yml", "security.yml"]) { + const workflow = parse(readFileSync(".github/workflows/" + file, "utf8")); + assert(workflow.on.push.branches.includes("3.x"), file); +} +const release = parse(readFileSync(".github/workflows/release.yml", "utf8")); +assert.equal(release.jobs["semantic-release"].steps.find(({ id }) => id === "semantic").with.semantic_version, + require("semantic-release/package.json").version, "Verification and publishing must use the same semantic-release version"); +for (const file of readdirSync(".github/workflows").filter(file => file.endsWith(".yml"))) { + const workflow = parse(readFileSync(".github/workflows/" + file, "utf8")); + for (const job of Object.values(workflow.jobs)) { + for (const step of job.steps ?? []) { + if (step.run) execFileSync("bash", ["-n"], { input: step.run }); + } + } +} +assert(release.jobs["bump-clients"].if.includes("startsWith(needs.semantic-release.outputs.release-version, '3.')")); +process.stdout.write("Release versions, workflow YAML, and downstream guard passed.\n"); diff --git a/internal/releasepolicy/policy_test.go b/internal/releasepolicy/policy_test.go new file mode 100644 index 000000000..6b86c6f3c --- /dev/null +++ b/internal/releasepolicy/policy_test.go @@ -0,0 +1,56 @@ +package releasepolicy + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "gopkg.in/yaml.v3" +) + +func TestReleasePolicy(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Release policy") +} + +var _ = Describe("major version release policy", func() { + root := filepath.Join("..", "..") + It("keeps maintenance releases in v3 and recognizes breaking releases", func() { + content, err := os.ReadFile(filepath.Join(root, ".releaserc")) + Expect(err).NotTo(HaveOccurred()) + var config struct { + Branches []struct { + Name string + Range string + } + Plugins []any + } + Expect(yaml.Unmarshal(content, &config)).To(Succeed()) + Expect(config.Branches).To(ContainElement(struct { + Name string + Range string + }{"3.x", "3.x"})) + analyzer := config.Plugins[0].([]any)[1].(map[string]any) + Expect(analyzer["releaseRules"]).To(ContainElement(map[string]any{"breaking": true, "release": "major"})) + }) + It("disables routine maintenance updates while retaining reviewed security updates", func() { + content, err := os.ReadFile(filepath.Join(root, "renovate.json")) + Expect(err).NotTo(HaveOccurred()) + var config map[string]any + Expect(json.Unmarshal(content, &config)).To(Succeed()) + Expect(config["baseBranchPatterns"]).To(ConsistOf("main", "3.x")) + Expect(config["osvVulnerabilityAlerts"]).To(Equal(true)) + Expect(config["packageRules"]).To(ContainElement(SatisfyAll( + HaveKeyWithValue("matchBaseBranches", []any{"3.x"}), + HaveKeyWithValue("matchPackageNames", []any{"*"}), + HaveKeyWithValue("enabled", false), + ))) + alerts := config["vulnerabilityAlerts"].(map[string]any) + Expect(alerts).To(HaveKeyWithValue("enabled", true)) + Expect(alerts).To(HaveKeyWithValue("automerge", false)) + Expect(alerts).To(HaveKeyWithValue("vulnerabilityFixStrategy", "lowest")) + }) +}) diff --git a/renovate.json b/renovate.json index 8bd3e334e..67b1bb9d9 100644 --- a/renovate.json +++ b/renovate.json @@ -1,5 +1,7 @@ { - "extends": ["schedule:weekends"], + "extends": [ + "schedule:weekends" + ], "separatePatchReleases": false, "prCreation": "immediate", "branchPrefix": "renovate/", @@ -16,7 +18,9 @@ "automerge": true }, "docker": { - "fileMatch": ["(^|/)Dockerfile.*$"], + "fileMatch": [ + "(^|/)Dockerfile.*$" + ], "major": { "enabled": true } @@ -30,5 +34,41 @@ "prHourlyLimit": 4, "labels": [ "dependencies" + ], + "baseBranchPatterns": [ + "main", + "3.x" + ], + "osvVulnerabilityAlerts": true, + "vulnerabilityAlerts": { + "enabled": true, + "automerge": false, + "vulnerabilityFixStrategy": "lowest", + "addLabels": [ + "security" + ] + }, + "packageRules": [ + { + "description": "Maintain v3 with reviewed security fixes only", + "matchBaseBranches": [ + "3.x" + ], + "matchPackageNames": [ + "*" + ], + "enabled": false, + "automerge": false + }, + { + "description": "Preserve the CEL module path on v3", + "matchBaseBranches": [ + "3.x" + ], + "matchPackageNames": [ + "github.com/google/cel-go" + ], + "allowedVersions": "<0.32.0" + } ] } diff --git a/web/packages/lang/src/generated/languages.json b/web/packages/lang/src/generated/languages.json index 45df51e67..29a25086f 100644 --- a/web/packages/lang/src/generated/languages.json +++ b/web/packages/lang/src/generated/languages.json @@ -237,12 +237,16 @@ ], "macros": [ "all", + "distinctBy", "exists", "exists_one", "filter", "fold", "greatest", + "groupBy", "has", + "joinBy", + "latestBy", "least", "map", "optFlatMap", @@ -291,6 +295,7 @@ "max", "min", "omit", + "one", "or", "orValue", "pick", diff --git a/web/packages/lang/src/generated/spec.json b/web/packages/lang/src/generated/spec.json index f521fa146..5f6db8887 100644 --- a/web/packages/lang/src/generated/spec.json +++ b/web/packages/lang/src/generated/spec.json @@ -74,6 +74,11 @@ "// an empty list or map as the range will result in a trivially true result\n[].all(x, x \u003e 0) // true" ] }, + { + "name": "distinctBy", + "argCount": 2, + "receiverStyle": true + }, { "name": "exists", "argCount": 2, @@ -135,6 +140,11 @@ "argCount": 0, "receiverStyle": true }, + { + "name": "groupBy", + "argCount": 2, + "receiverStyle": true + }, { "name": "has", "argCount": 1, @@ -146,6 +156,16 @@ "// test whether the 'id' field is set to a non-default value on the Expr{} message literal\nhas(Expr{}.id) // false" ] }, + { + "name": "joinBy", + "argCount": 5, + "receiverStyle": true + }, + { + "name": "latestBy", + "argCount": 2, + "receiverStyle": true + }, { "name": "least", "argCount": 0, @@ -3214,6 +3234,20 @@ } ] }, + { + "name": "one", + "memberOnly": true, + "overloads": [ + { + "id": "collection_one", + "args": [ + "list(dyn)" + ], + "result": "dyn", + "member": true + } + ] + }, { "name": "optional.none", "namespace": "optional", @@ -4077,6 +4111,14 @@ "name": "sum", "memberOnly": true, "overloads": [ + { + "id": "collection_sum_decimal_strings", + "args": [ + "list(string)" + ], + "result": "string", + "member": true + }, { "id": "list_double_sum_double", "args": [ diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index fc822927f..cd447a027 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -1045,8 +1045,8 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} - dompurify@3.4.13: - resolution: {integrity: sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==} + dompurify@3.4.17: + resolution: {integrity: sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w==} electron-to-chromium@1.5.405: resolution: {integrity: sha512-bNglH7lPH5l+yHOes7Zr4VqxhOy4BQ9ZBUX4VdoFgxMpzJk7W1ZoO3Vgd9Pxa9PyjQ76sfm2aKH/nzEcCNRlew==} @@ -1938,7 +1938,7 @@ snapshots: class-variance-authority: 0.7.1 clsx: 2.1.1 country-flag-icons: 1.6.20 - dompurify: 3.4.13 + dompurify: 3.4.17 jotai: 2.20.2(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.2.18)(react@19.2.8) jsonc-parser: 3.3.1 monaco-editor: 0.48.0 @@ -2452,7 +2452,7 @@ snapshots: detect-libc@2.1.2: {} - dompurify@3.4.13: + dompurify@3.4.17: optionalDependencies: '@types/trusted-types': 2.0.7