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 0a175c8a2..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: 10 + 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/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.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_collections.go b/cel_collections.go new file mode 100644 index 000000000..6f3ee4681 --- /dev/null +++ b/cel_collections.go @@ -0,0 +1,203 @@ +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" +) + +// collectionCelOptions registers collection macros and their runtime bindings. +func collectionCelOptions() []cel.EnvOption { + 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, + } { + 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))), + ) +} + +// 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) + if err != nil { + return nil, err + } + return factory.NewCall("gomplate.@"+name, projection), nil + } +} + +// 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 { + 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 +} + +// 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 { + 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 +} + +// 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 { + 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 +} + +// groupRows returns key/rows maps without converting native row values. +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) +} + +// 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 { + return err + } + rows := make([]ref.Val, len(groups)) + for index, group := range groups { + rows[index] = group.rows[0] + } + 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) { + return types.NewErr("one requires exactly one row, got %v", list.Size()) + } + 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 { + 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 +} + +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 { + 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 { + 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})) + } + } + 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..361b729f3 --- /dev/null +++ b/cel_collections_spec_test.go @@ -0,0 +1,91 @@ +package gomplate + +import ( + "strings" + + . "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("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}) + 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/cel_decimal.go b/cel_decimal.go new file mode 100644 index 000000000..3a8c05338 --- /dev/null +++ b/cel_decimal.go @@ -0,0 +1,48 @@ +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]+)?$`) + +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) + } + 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..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 { @@ -68,11 +70,15 @@ 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 } +// 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 { + base, err = strictBaseCelEnv() + } if err != nil { return nil, nil, err } @@ -108,13 +114,14 @@ 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 { 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 @@ -136,9 +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 { - if nativeTypeGeneration == 0 { - return t.cacheKey(environment) - } - return 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 9c29e22fe..aed618f59 100644 --- a/cel_result.go +++ b/cel_result.go @@ -1,59 +1,51 @@ 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. +// Maps with keys that Go cannot hash retain their CEL representation. +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)) + if nativeKey != nil && !reflect.ValueOf(nativeKey).Comparable() { + return out.Value() + } + 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 +66,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/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 new file mode 100644 index 000000000..1b522d28d --- /dev/null +++ b/docs/content/cel-collections.md @@ -0,0 +1,32 @@ +--- +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`. + +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) +``` + +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. diff --git a/go.mod b/go.mod index 8cb12cfd5..2becf77bd 100644 --- a/go.mod +++ b/go.mod @@ -1,14 +1,14 @@ module github.com/flanksource/gomplate/v3 -go 1.26.0 +go 1.26.10 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.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 @@ -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.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 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..ba86c13e6 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.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= @@ -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.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= @@ -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.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= 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.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= @@ -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.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.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= -golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +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= @@ -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/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/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/template.go b/template.go index c6edeeae4..c36f782f7 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 @@ -72,6 +73,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). + // + // 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:"-"` // CacheTime controls how long the compiled program/template is retained @@ -124,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 { @@ -133,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 @@ -170,6 +177,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. 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 +} + func (t Template) IsEmpty() bool { return t.Template == "" && t.JSONPath == "" && t.Expression == "" && t.Javascript == "" } @@ -239,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). @@ -290,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.IsCacheable() { - cached, ok := goTemplateCache.Get(template.cacheKey(nil)) + if template.isGoTemplateCacheable() { + cached, ok := goTemplateCache.Get(cacheKey) if ok { if cachedTpl, ok := cached.(*gotemplate.Template); ok { if ctx.Logger != nil && properties.On(false, "gomplate.log") { @@ -306,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) @@ -329,8 +349,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(cacheKey, 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") + } +} 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"]) + } +} 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/apps/playground/plugins/eval-server.ts b/web/apps/playground/plugins/eval-server.ts index cce616911..674edbc75 100644 --- a/web/apps/playground/plugins/eval-server.ts +++ b/web/apps/playground/plugins/eval-server.ts @@ -1,74 +1,91 @@ -import { spawn, type ChildProcess } from "node:child_process"; -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; - /** Port the Go server listens on. */ + /** Loopback host the Go server listens on. */ + host: string; + /** Preferred port; Go retains its listener or allocates another on conflict. */ port: number; } -/** - * 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. - */ -export function evalServer({ repoRoot, port }: EvalServerOptions): Plugin { - let child: ChildProcess | undefined; - +/** 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 = () => { - if (!child || child.killed) return; - child.kill("SIGTERM"); - child = undefined; + // Closing stdin reaches the compiled grandchild as well as `go run`. + child.stdin.end(); }; + return new Promise((resolve, reject) => { + 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; + } + 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 the evaluator during development and points Vite at its bound port. + * Set GOMPLATE_PLAYGROUND_SERVER=0 to manage the evaluator yourself. + */ +export function evalServer(options: EvalServerOptions): Plugin { + let stopChild: (() => void) | undefined; + const stop = () => { + 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", `:${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 new file mode 100644 index 000000000..25fa6bbfc --- /dev/null +++ b/web/apps/playground/test/viteConfig.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; + +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}`, + }); + }); + +}); diff --git a/web/apps/playground/vite.config.ts b/web/apps/playground/vite.config.ts index ae9212238..b832c8c58 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"; 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,27 @@ 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; +} + +/** Configures source aliases, dependency optimization and the evaluator proxy. */ +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 +68,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 +76,17 @@ 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(({ command, mode }) => + createPlaygroundConfig({ command, mode, clickySourceAvailable, evalPort: EVAL_PORT }), +); 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" } 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 96f252c03..cd447a027 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==} @@ -1032,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==} @@ -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 - dompurify: 3.4.13 + country-flag-icons: 1.6.20 + 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 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: @@ -2425,7 +2452,7 @@ snapshots: detect-libc@2.1.2: {} - dompurify@3.4.13: + dompurify@3.4.17: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -2805,7 +2832,7 @@ snapshots: tabbable@6.5.0: {} - tailwind-merge@2.6.1: {} + tailwind-merge@3.7.0: {} tailwindcss@4.3.3: {}