From b0f87e2aed8a504607499976ce019746911536d7 Mon Sep 17 00:00:00 2001 From: Roman Berezkin Date: Wed, 5 Aug 2026 12:53:06 +0300 Subject: [PATCH 1/2] [package] Update the vendored plugin to v0.0.31 - verify: new "openapi/enum" rule. Enum values in OpenAPI schemas must be CamelCase and start with a capital letter. - verify: new "openapi/bilingual" rule. Every schema under openapi/ must have a doc-ru-*.yaml translation next to it. values.yaml and test fixtures are exempt. - bootstrap: module and application templates now include openapi/doc-ru-settings.yaml, so new packages pass the bilingual rule out of the box. - render: a bare x-example on an array property now becomes a one-element list, so toYaml and range in templates get the right shape. - render: the stubbed publicDomainTemplate now matches the real platform format ("%s.%s.domain.io"). Signed-off-by: Roman Berezkin --- .../packagecmd/internal/packages/render.go | 2 +- .../values/schema/defaults/generator.go | 20 +- .../internal/verify/lint/doc/openapi.go | 73 ++++++- .../verify/lint/linters/openapi/linter.go | 9 +- .../lint/linters/openapi/rules/advanced.go | 2 - .../lint/linters/openapi/rules/bilingual.go | 79 +++++++ .../verify/lint/linters/openapi/rules/enum.go | 200 ++++++++++++++++++ .../lint/linters/openapi/rules/schemas.go | 68 ++++++ internal/packagecmd/packagecmd.go | 2 +- .../application/openapi/doc-ru-settings.yaml | 5 + .../application/openapi/settings.yaml | 2 + .../module/openapi/doc-ru-settings.yaml | 5 + .../templates/module/openapi/settings.yaml | 2 + 13 files changed, 459 insertions(+), 10 deletions(-) create mode 100644 internal/packagecmd/internal/verify/lint/linters/openapi/rules/bilingual.go create mode 100644 internal/packagecmd/internal/verify/lint/linters/openapi/rules/enum.go create mode 100644 internal/packagecmd/internal/verify/lint/linters/openapi/rules/schemas.go create mode 100644 internal/packagecmd/templates/application/openapi/doc-ru-settings.yaml create mode 100644 internal/packagecmd/templates/module/openapi/doc-ru-settings.yaml diff --git a/internal/packagecmd/internal/packages/render.go b/internal/packagecmd/internal/packages/render.go index afb71a3f8..1401eb72c 100644 --- a/internal/packagecmd/internal/packages/render.go +++ b/internal/packagecmd/internal/packages/render.go @@ -138,7 +138,7 @@ func buildPlatformValues() any { "mode": "CertManager", }, "ingressClass": "nginx", - "publicDomainTemplate": "%s.%s.%s.domain.io", + "publicDomainTemplate": "%s.%s.domain.io", }, } } diff --git a/internal/packagecmd/internal/packages/values/schema/defaults/generator.go b/internal/packagecmd/internal/packages/values/schema/defaults/generator.go index 77d66bc6b..1c6478bb4 100644 --- a/internal/packagecmd/internal/packages/values/schema/defaults/generator.go +++ b/internal/packagecmd/internal/packages/values/schema/defaults/generator.go @@ -227,11 +227,12 @@ func mergedProperties(s *spec.Schema) map[string]spec.Schema { // the example is layered on top — this lets schema authors write a partial // `x-example` and have unset fields filled from per-property defaults. // -// For non-object schemas, the example is returned as a deep clone. +// For non-object schemas, the example is returned as a deep clone, shaped as a +// list when the schema declares an array. func overlayExample(s *spec.Schema, example any) (any, error) { exMap, ok := example.(map[string]any) if !ok || !isObject(s) { - return deepCopyJSON(example), nil + return arrayShaped(s, deepCopyJSON(example)), nil } base, err := synthesizeObject(s) @@ -244,6 +245,21 @@ func overlayExample(s *spec.Schema, example any) (any, error) { return base, nil } +// arrayShaped keeps the shape the schema declares. An array property is often +// illustrated with a single element rather than a one-element list; a chart that +// renders such a value with toYaml or ranges over it needs the list. +func arrayShaped(s *spec.Schema, v any) any { + if !s.Type.Contains(typeArray) { + return v + } + + if _, isList := v.([]any); isList { + return v + } + + return []any{v} +} + // firstExample picks a representative value out of an x-examples block. // Three forms are recognized: // diff --git a/internal/packagecmd/internal/verify/lint/doc/openapi.go b/internal/packagecmd/internal/verify/lint/doc/openapi.go index 07cdc4ad5..500e178eb 100644 --- a/internal/packagecmd/internal/verify/lint/doc/openapi.go +++ b/internal/packagecmd/internal/verify/lint/doc/openapi.go @@ -12,7 +12,7 @@ var openapiDoc = Linter{ Summary: "The OpenAPI schemas a package ships under openapi/", Description: []string{ "Checks the schemas that describe a package's settings, rather than the values rendered from them — a rendered manifest is the concern of the templates linter.", - "Only openapi/settings.yaml is inspected, because it is the schema the UI builds a settings form from.", + "Only the top level of openapi/ is read, because that is where the runtime looks a package's schemas up. openapi/settings.yaml is the one every rule has something to say about, being the schema the UI builds a settings form from.", }, Rules: []Rule{ { @@ -59,6 +59,77 @@ var openapiDoc = Linter{ "A package without openapi/settings.yaml exposes no settings and reports nothing.", }, }, + { + ID: rules.EnumRuleID, + Impact: lint.Warn.Ptr(), + Summary: "Requires enum values to be CamelCase", + Description: []string{ + "A setting that lists the values it accepts is declaring API constants, and the Kubernetes API conventions spell those in CamelCase with an initial capital: ClusterFirst, Pending, ClientIP.", + "Every schema in openapi/ is checked, and every enum in it, however deeply nested. Translations and -tests.yaml fixtures are skipped: they carry descriptions and values rather than a schema.", + "An acronym keeps all its letters capital, as in ClientIP or TCPDelay. Digits are allowed anywhere, and a dot is allowed inside a number, so Version2 and TLS1.3 are accepted while a space, hyphen or underscore is not.", + }, + Reports: []string{ + "an enum value starts with a lower-case letter", + "an enum value contains anything other than letters, digits and dots inside numbers", + "a schema under openapi/ cannot be read or is not valid YAML", + }, + Example: Example{ + Reported: []string{ + "properties:", + " logLevel:", + " type: string", + " enum:", + " - debug # starts lower-case", + " - error-level # hyphen", + }, + Accepted: []string{ + "properties:", + " logLevel:", + " type: string", + " enum:", + " - Debug", + " - Error", + }, + }, + Fix: "Rename the enum values to CamelCase and map them onto whatever the application expects in the template.", + Notes: []string{ + "The reported value is the pointer of the enum holding the value, so it maps straight onto the lines of the schema.", + "Values that are not strings are skipped, because a boolean or numeric constant carries no casing.", + "This rule advises rather than blocks. An application whose settings pass values straight through to an upstream configuration file cannot follow the convention without translating every value back in the template, which is why a finding here is a warning.", + }, + }, + { + ID: rules.BilingualRuleID, + Impact: lint.Error.Ptr(), + Summary: "Requires a Russian translation for every settings schema", + Description: []string{ + "Settings are published in both languages. The translation is a second schema next to the first, carrying the same property tree with Russian descriptions and nothing else, named after the schema it translates with a doc-ru- prefix.", + "openapi/values.yaml is exempt. It describes the values computed for the templates rather than the settings a user writes, so it holds no user-facing descriptions to translate. A translation of it is accepted but never required.", + "The check runs both ways, so a translation whose schema is missing is reported too — which is how a misspelled doc-ru- name surfaces instead of silently translating nothing.", + }, + Reports: []string{ + "a schema under openapi/ other than values.yaml has no doc-ru- counterpart next to it", + "a doc-ru- file under openapi/ has no schema of that name next to it", + }, + Example: Example{ + Reported: []string{ + "my-package/openapi/", + " settings.yaml", + " values.yaml # exempt", + }, + Accepted: []string{ + "my-package/openapi/", + " settings.yaml", + " doc-ru-settings.yaml", + " values.yaml", + }, + }, + Fix: "Add openapi/doc-ru-.yaml next to every schema, holding the property tree of the schema with Russian descriptions.", + Notes: []string{ + "A translation carries descriptions only. Types, defaults and enums stay in the schema itself, where the runtime validates against them.", + "A package without openapi/ ships no schemas and reports nothing.", + }, + }, }, Notes: []string{ "The openapi linter is a hard schema contract: it has no .pkglint.yaml settings, and its rules report at their built-in severity.", diff --git a/internal/packagecmd/internal/verify/lint/linters/openapi/linter.go b/internal/packagecmd/internal/verify/lint/linters/openapi/linter.go index 47040289a..96babcf65 100644 --- a/internal/packagecmd/internal/verify/lint/linters/openapi/linter.go +++ b/internal/packagecmd/internal/verify/lint/linters/openapi/linter.go @@ -1,6 +1,7 @@ -// Package openapi validates the OpenAPI schemas a package ships under openapi/. Its -// rules encode hard schema contracts rather than preferences, so the linter carries no -// .pkglint.yaml settings and its rules always report at their built-in severity. +// Package openapi validates the OpenAPI schemas a package ships under openapi/. Only the +// top level of the directory is read, because that is where the runtime looks a package's +// schemas up. Its rules encode schema contracts rather than preferences, so the linter +// carries no .pkglint.yaml settings and its rules always report at their built-in severity. package openapi import ( @@ -41,4 +42,6 @@ type Linter struct { // Lint executes the openapi rules against the configured package path. func (l *Linter) Lint(ctx context.Context) { rules.NewAdvancedRule(l.config.Path, l.collector).Check(ctx) + rules.NewEnumRule(l.config.Path, l.collector).Check(ctx) + rules.NewBilingualRule(l.config.Path, l.collector).Check(ctx) } diff --git a/internal/packagecmd/internal/verify/lint/linters/openapi/rules/advanced.go b/internal/packagecmd/internal/verify/lint/linters/openapi/rules/advanced.go index 3e39d0ba2..a19dd5825 100644 --- a/internal/packagecmd/internal/verify/lint/linters/openapi/rules/advanced.go +++ b/internal/packagecmd/internal/verify/lint/linters/openapi/rules/advanced.go @@ -20,8 +20,6 @@ import ( const AdvancedRuleID = "advanced" const ( - // openAPIDir is the package subdirectory holding the OpenAPI schemas. - openAPIDir = "openapi" // settingsFile is the OpenAPI schema describing user-configurable settings. settingsFile = "settings.yaml" // advancedKey is the vendor extension that marks a setting as advanced in the UI. diff --git a/internal/packagecmd/internal/verify/lint/linters/openapi/rules/bilingual.go b/internal/packagecmd/internal/verify/lint/linters/openapi/rules/bilingual.go new file mode 100644 index 000000000..aab564359 --- /dev/null +++ b/internal/packagecmd/internal/verify/lint/linters/openapi/rules/bilingual.go @@ -0,0 +1,79 @@ +package rules + +import ( + "context" + "path/filepath" + "strings" + + "github.com/deckhouse/deckhouse-cli/internal/packagecmd/internal/verify/lint/diag" +) + +// Rule purpose: require a Russian translation next to every schema that documents +// user-facing settings. + +// BilingualRuleID is the stable identifier used to reference this rule in configuration. +const BilingualRuleID = "bilingual" + +// BilingualRule checks that the schemas under openapi/ and their translations come in pairs. +type BilingualRule struct { + collector *diag.Collector + path string +} + +// NewBilingualRule constructs a BilingualRule scoped to a package directory. Which schemas +// it pairs up is fixed, so the rule resolves them from packageDir itself. +func NewBilingualRule(packageDir string, collector *diag.Collector) *BilingualRule { + return &BilingualRule{ + path: packageDir, + collector: collector.With(diag.RuleID(BilingualRuleID)), + } +} + +// Check reports a schema whose translation is missing and a translation whose schema is +// missing, the latter being how a misspelled translation surfaces. values.yaml is exempt: +// it describes the values computed for the templates rather than the settings a user +// writes, so it carries no user-facing descriptions to translate. So are the test +// fixtures, which hold values rather than a schema. +func (r *BilingualRule) Check(_ context.Context) { + names, err := schemaNames(r.path) + if err != nil { + r.collector.With( + diag.Path(openAPIDir), + diag.Value(err.Error())). + Error("cannot read the openapi directory") + + return + } + + present := make(map[string]struct{}, len(names)) + for _, name := range names { + present[name] = struct{}{} + } + + for _, name := range names { + if isTestFixture(name) { + continue + } + + collector := r.collector.With(diag.Path(filepath.Join(openAPIDir, name))) + + if isTranslation(name) { + translated := strings.TrimPrefix(name, docRuPrefix) + if _, ok := present[translated]; !ok { + collector.Error("translation has nothing to translate: %s is missing next to it", translated) + } + + continue + } + + if name == valuesFile { + continue + } + + if _, ok := present[docRuPrefix+name]; !ok { + collector.Error("translation is missing: need to create %s next to it", docRuPrefix+name) + } + } + + r.collector.Commit() +} diff --git a/internal/packagecmd/internal/verify/lint/linters/openapi/rules/enum.go b/internal/packagecmd/internal/verify/lint/linters/openapi/rules/enum.go new file mode 100644 index 000000000..d9a55bf6d --- /dev/null +++ b/internal/packagecmd/internal/verify/lint/linters/openapi/rules/enum.go @@ -0,0 +1,200 @@ +package rules + +import ( + "cmp" + "context" + "os" + "path/filepath" + "slices" + "strconv" + "unicode" + + "sigs.k8s.io/yaml" + + "github.com/deckhouse/deckhouse-cli/internal/packagecmd/internal/verify/lint/diag" +) + +// Rule purpose: keep the enum values of the schemas in the CamelCase the Kubernetes API +// conventions prescribe for constants. + +// EnumRuleID is the stable identifier used to reference this rule in configuration. +const EnumRuleID = "enum" + +// enumKeyword is the schema keyword listing the values a setting is allowed to take. +const enumKeyword = "enum" + +// EnumRule checks that every enum value in a package's schemas is CamelCase. +type EnumRule struct { + collector *diag.Collector + path string +} + +// NewEnumRule constructs an EnumRule scoped to a package directory. Which schemas it +// reads is fixed, so the rule resolves them from packageDir itself. +func NewEnumRule(packageDir string, collector *diag.Collector) *EnumRule { + return &EnumRule{ + path: packageDir, + collector: collector.With(diag.RuleID(EnumRuleID)), + } +} + +// Check reports every enum value of every schema that breaks the CamelCase convention. +// Translations are skipped because they carry descriptions rather than values, and so are +// test fixtures, which hold values rather than a schema. +// +// Findings are warnings. The convention is what Kubernetes prescribes for its own API +// constants, but a package whose settings pass values straight through to an upstream +// configuration file cannot follow it without translating every value back in the +// template, so the rule advises rather than blocks. +func (r *EnumRule) Check(_ context.Context) { + names, err := schemaNames(r.path) + if err != nil { + r.collector.With( + diag.Path(openAPIDir), + diag.Value(err.Error())). + Warn("cannot read the openapi directory") + + return + } + + for _, name := range names { + if isTranslation(name) || isTestFixture(name) { + continue + } + + r.checkSchema(name) + } + + r.collector.Commit() +} + +// checkSchema reports the invalid enum values of one schema file. +func (r *EnumRule) checkSchema(name string) { + collector := r.collector.With(diag.Path(filepath.Join(openAPIDir, name))) + + raw, err := os.ReadFile(filepath.Join(r.path, openAPIDir, name)) + if err != nil { + collector.Warn("failed to read %s: %v", name, err) + + return + } + + var root map[string]any + if err = yaml.Unmarshal(raw, &root); err != nil { + collector.Warn("failed to parse %s: %v", name, err) + + return + } + + for _, finding := range invalidEnumValues(root) { + collector.With(diag.Value(finding.pointer)). + Warn("enum value %q %s", finding.value, finding.reason) + } +} + +// enumFinding is one invalid value of one enum. +type enumFinding struct { + // pointer locates the enum keyword holding the value inside the schema. + pointer string + // value is the offending enum value. + value string + // reason states what the value breaks. + reason string +} + +// invalidEnumValues returns a finding for every enum value in root that breaks the +// convention, sorted so the same schema always reports in the same order. +func invalidEnumValues(root map[string]any) []enumFinding { + var found []enumFinding + + collectInvalidEnums(root, "", &found) + + slices.SortFunc(found, func(a, b enumFinding) int { + return cmp.Or( + cmp.Compare(a.pointer, b.pointer), + cmp.Compare(a.value, b.value)) + }) + + return found +} + +// collectInvalidEnums walks everything below node, appending a finding for each invalid +// value of every enum it passes. The whole document is walked rather than the +// nested-schema keywords alone, because an enum constrains a value wherever it appears; +// a setting that merely shares the keyword's name holds a schema instead of a list of +// values, which is what the type check keeps apart. +func collectInvalidEnums(node map[string]any, pointer string, found *[]enumFinding) { + for key, value := range node { + keyPointer := joinPointer(pointer, key) + + if values, ok := value.([]any); ok && key == enumKeyword { + *found = append(*found, invalidValues(keyPointer, values)...) + + continue + } + + switch nested := value.(type) { + case map[string]any: + collectInvalidEnums(nested, keyPointer, found) + case []any: + for i, item := range nested { + if child, ok := item.(map[string]any); ok { + collectInvalidEnums(child, joinPointer(keyPointer, strconv.Itoa(i)), found) + } + } + } + } +} + +// invalidValues returns a finding for each value of one enum that breaks the convention. +// A value that is not a string is skipped: a boolean or numeric constant carries no casing. +func invalidValues(pointer string, values []any) []enumFinding { + findings := make([]enumFinding, 0, len(values)) + + for _, value := range values { + str, ok := value.(string) + if !ok { + continue + } + + if reason := conventionBreach(str); reason != "" { + findings = append(findings, enumFinding{ + pointer: pointer, + value: str, + reason: reason, + }) + } + } + + return findings +} + +// conventionBreach returns what value breaks in the CamelCase convention, or an empty +// string when it breaks nothing. An empty value stands for "unset" rather than a name, +// so it is accepted, and a value opening with a non-letter is left to start as it does: +// the leading-capital requirement is about words, not about digits or symbols. +func conventionBreach(value string) string { + if value == "" { + return "" + } + + runes := []rune(value) + + if unicode.IsLetter(runes[0]) && !unicode.IsUpper(runes[0]) { + return "must start with a capital letter" + } + + for i, char := range runes { + switch { + case unicode.IsLetter(char), unicode.IsNumber(char): + continue + // A dot belongs to a version or a fraction, so it has to follow a digit. + case char == '.' && i > 0 && unicode.IsNumber(runes[i-1]): + continue + default: + return "must be in CamelCase" + } + } + + return "" +} diff --git a/internal/packagecmd/internal/verify/lint/linters/openapi/rules/schemas.go b/internal/packagecmd/internal/verify/lint/linters/openapi/rules/schemas.go new file mode 100644 index 000000000..6b47fb5eb --- /dev/null +++ b/internal/packagecmd/internal/verify/lint/linters/openapi/rules/schemas.go @@ -0,0 +1,68 @@ +package rules + +import ( + "os" + "path/filepath" + "slices" + "strings" +) + +// Every rule in this package reads the schemas a package ships under openapi/, so the +// listing and the filename conventions that classify one live here rather than in a rule. + +const ( + // openAPIDir is the package subdirectory holding the OpenAPI schemas. + openAPIDir = "openapi" + // valuesFile is the schema of the values the templates are rendered with. It + // describes computed internal values rather than the settings a user writes. + valuesFile = "values.yaml" + // docRuPrefix marks the Russian translation of the schema whose name follows it. + docRuPrefix = "doc-ru-" + // testsSuffix marks the test fixtures a schema may ship alongside itself. A fixture + // holds values rather than a schema, so no rule reads one. + testsSuffix = "-tests.yaml" +) + +// schemaNames returns the names of the schema files directly under packageDir/openapi, +// sorted so a directory always reports its findings in the same order. Only the top +// level is listed, because that is where the runtime reads a package's schemas from. +// An absent directory yields no names rather than an error: a package that ships no +// schemas is not a rule's concern. +func schemaNames(packageDir string) ([]string, error) { + entries, err := os.ReadDir(filepath.Join(packageDir, openAPIDir)) + if os.IsNotExist(err) { + return nil, nil + } + + if err != nil { + return nil, err + } + + names := make([]string, 0, len(entries)) + + for _, entry := range entries { + if !entry.Type().IsRegular() { + continue + } + + if ext := filepath.Ext(entry.Name()); ext != ".yaml" && ext != ".yml" { + continue + } + + names = append(names, entry.Name()) + } + + slices.Sort(names) + + return names, nil +} + +// isTranslation reports whether name is the Russian translation of another schema. +func isTranslation(name string) bool { + return strings.HasPrefix(name, docRuPrefix) +} + +// isTestFixture reports whether name holds schema test fixtures rather than a schema. +func isTestFixture(name string) bool { + return strings.HasSuffix(name, testsSuffix) +} diff --git a/internal/packagecmd/packagecmd.go b/internal/packagecmd/packagecmd.go index da227522c..2499084ad 100644 --- a/internal/packagecmd/packagecmd.go +++ b/internal/packagecmd/packagecmd.go @@ -15,7 +15,7 @@ import ( // entry point (cmd/package/main.go) and the "version" subcommand, whose value is // injected by the plugin's own ldflags. d8 reports its version itself. // -// Vendored from d8-package-plugin v0.0.30 (23f3072). Keep this in sync when +// Vendored from d8-package-plugin v0.0.31 (8899c68). Keep this in sync when // re-syncing internal/, pkg/ and templates/ from upstream. func NewCommand() *cobra.Command { return pkgcmd.NewCmdRoot() diff --git a/internal/packagecmd/templates/application/openapi/doc-ru-settings.yaml b/internal/packagecmd/templates/application/openapi/doc-ru-settings.yaml new file mode 100644 index 000000000..fff590bdb --- /dev/null +++ b/internal/packagecmd/templates/application/openapi/doc-ru-settings.yaml @@ -0,0 +1,5 @@ +properties: + replicas: + description: Количество запускаемых реплик сервера. + msg: + description: Текст, которым сервер отвечает на каждый запрос. diff --git a/internal/packagecmd/templates/application/openapi/settings.yaml b/internal/packagecmd/templates/application/openapi/settings.yaml index d509b407e..e59ebfd01 100644 --- a/internal/packagecmd/templates/application/openapi/settings.yaml +++ b/internal/packagecmd/templates/application/openapi/settings.yaml @@ -4,5 +4,7 @@ properties: replicas: type: integer default: 1 + description: Number of server replicas to run. msg: type: string + description: Text the server answers every request with. diff --git a/internal/packagecmd/templates/module/openapi/doc-ru-settings.yaml b/internal/packagecmd/templates/module/openapi/doc-ru-settings.yaml new file mode 100644 index 000000000..fff590bdb --- /dev/null +++ b/internal/packagecmd/templates/module/openapi/doc-ru-settings.yaml @@ -0,0 +1,5 @@ +properties: + replicas: + description: Количество запускаемых реплик сервера. + msg: + description: Текст, которым сервер отвечает на каждый запрос. diff --git a/internal/packagecmd/templates/module/openapi/settings.yaml b/internal/packagecmd/templates/module/openapi/settings.yaml index d509b407e..e59ebfd01 100644 --- a/internal/packagecmd/templates/module/openapi/settings.yaml +++ b/internal/packagecmd/templates/module/openapi/settings.yaml @@ -4,5 +4,7 @@ properties: replicas: type: integer default: 1 + description: Number of server replicas to run. msg: type: string + description: Text the server answers every request with. From 80ecff9794bc95438ec0361c723fe6e21898898f Mon Sep 17 00:00:00 2001 From: Roman Berezkin Date: Wed, 5 Aug 2026 13:57:20 +0300 Subject: [PATCH 2/2] [snapshot] Fix wsl_v5 lint issues Four statements in archive/atomic.go and transport/http.go lacked a blank line above them. They made "task lint:check" red on main since the snapshot command landed (e4cd67a5). Signed-off-by: Roman Berezkin --- internal/snapshot/archive/atomic.go | 2 ++ internal/snapshot/transport/http.go | 2 ++ 2 files changed, 4 insertions(+) diff --git a/internal/snapshot/archive/atomic.go b/internal/snapshot/archive/atomic.go index a8cc85633..db693b89c 100644 --- a/internal/snapshot/archive/atomic.go +++ b/internal/snapshot/archive/atomic.go @@ -328,6 +328,7 @@ func (d *RootedDestination) recordBindingLoss(err error) error { } d.mu.Lock() + if d.lost == nil { d.lost = err } @@ -1249,6 +1250,7 @@ func (d *RootedDestination) Close() error { } d.mu.Lock() + if d.closed { d.mu.Unlock() diff --git a/internal/snapshot/transport/http.go b/internal/snapshot/transport/http.go index 469fea679..cd5ff0710 100644 --- a/internal/snapshot/transport/http.go +++ b/internal/snapshot/transport/http.go @@ -181,6 +181,7 @@ func (l *ownedTransportLifecycle) trackConnection(conn net.Conn) net.Conn { } l.mu.Lock() + if l.closing { l.mu.Unlock() @@ -239,6 +240,7 @@ func (l *ownedTransportLifecycle) closeConnections() { for conn := range l.connections { connections = append(connections, conn) } + l.mu.Unlock() for _, conn := range connections {