You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@skywalking.apache.org by li...@apache.org on 2026/09/23 12:41:51 UTC

(skywalking-banyandb) branch node-ext updated (e6b03bff2 -> cb4bba202)

This is an automated email from the ASF dual-hosted git repository.

mrproliu pushed a change to branch node-ext
in repository https://gitbox.apache.org/repos/asf/skywalking-banyandb.git


 discard e6b03bff2 Extend `databasev1.Node` to support report file format versions and timezone
     new cb4bba202 Extend `databasev1.Node` to support report file format versions and timezone

This update added new revisions after undoing existing revisions.
That is to say, some revisions that were in the old version of the
branch are not in the new version.  This situation occurs
when a user --force pushes a change and generates a repository
containing something like this:

 * -- * -- B -- O -- O -- O   (e6b03bff2)
            \
             N -- N -- N   refs/heads/node-ext (cb4bba202)

You should already have received notification emails for all of the O
revisions, and so the following emails describe only the N revisions
from the common base, B.

Any revisions marked "omit" are not gone; other references still
refer to them.  Any revisions marked "discard" are gone forever.

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 banyand/internal/storage/tzdata.go               | 39 ++++++++++++++++++++++++
 test/e2e-v2/cases/cluster/expected/node-info.yml | 23 ++------------
 2 files changed, 41 insertions(+), 21 deletions(-)
 create mode 100644 banyand/internal/storage/tzdata.go


(skywalking-banyandb) 01/01: Extend `databasev1.Node` to support report file format versions and timezone

Posted by li...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

mrproliu pushed a commit to branch node-ext
in repository https://gitbox.apache.org/repos/asf/skywalking-banyandb.git

commit cb4bba2024c188b452e27e8f234cc07b211daddc
Author: mrproliu <74...@qq.com>
AuthorDate: Wed Sep 23 13:58:30 2026 +0800

    Extend `databasev1.Node` to support report file format versions and timezone
---
 CHANGES.md                                         |   1 +
 Makefile                                           |   4 +
 api/common/id.go                                   |  14 +-
 api/common/id_test.go                              |  54 ++++++
 api/proto/banyandb/database/v1/database.proto      |   7 +
 banyand/backup/lifecycle/service.go                |  15 +-
 banyand/internal/storage/tzdata.go                 |  39 ++++
 banyand/internal/storage/version.go                |  34 +---
 docs/api-reference.md                              |   2 +
 pkg/fileformat/version.go                          |  64 +++++++
 pkg/fileformat/version_test.go                     |  28 +++
 .../storage => pkg/fileformat}/versions.yml        |   0
 pkg/host/testdata/abidjan.tzif                     | Bin 0 -> 148 bytes
 pkg/host/testdata/shanghai.tzif                    |   0
 pkg/host/testdata/utc.tzif                         | Bin 0 -> 114 bytes
 pkg/host/timezone.go                               |  33 ++++
 pkg/host/timezone_cldr_windows.go                  | 168 +++++++++++++++++
 pkg/host/timezone_unix.go                          | 138 ++++++++++++++
 pkg/host/timezone_unix_test.go                     | 202 +++++++++++++++++++++
 pkg/host/timezone_windows.go                       |  51 ++++++
 test/e2e-v2/cases/cluster/e2e.yaml                 |   2 +
 test/e2e-v2/cases/cluster/expected/node-info.yml   |  71 ++++++++
 .../distributed/cluster_state/common.go            |  41 +++++
 .../integration/standalone/other/node_info_test.go |  79 ++++++++
 24 files changed, 1007 insertions(+), 40 deletions(-)

diff --git a/CHANGES.md b/CHANGES.md
index 8b2fa8700..9def365a2 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -13,6 +13,7 @@ Release Notes.
 - Push the criteria tag filter ahead of the vectorized stream merge, so a filtered index-order query bounds its merge at limit+offset. For such a query, where several rows share an ElementID, the criteria is now evaluated first and the element is represented by its first matching row in the requested sort order. Filtered timestamp-order queries are unchanged.
 - `measure.v1.QueryRequest.Aggregation` can now target a tag (`tag_name` + `tag_family`) instead of a field: `SUM`/`MIN`/`MAX`/`MEAN` over an `INT` tag, and `COUNT` over any scalar tag, in both standalone and distributed queries. Wire-additive only — existing clients that only ever set `field_name` are unaffected. `COUNT_DISTINCT` is not yet implemented; see the [tag aggregation and time bucketing design](docs/design/0.12.0/tag-aggregation/README.md).
 - `measure.v1.QueryRequest.GroupBy.time_bucket` groups a measure query by tumbling time windows, alone or combined with a tag `GroupBy`, in both standalone and distributed queries. The bucket width comes from the request or falls back to the measure's own `interval`. The part-scan path streams bucket-by-bucket so live memory never exceeds one bucket's groups; index-mode measures (no ascending-timestamp guarantee) use a bounded non-streaming fallback instead. A mixed-version cluster where [...]
+- `databasev1.Node` now reports its supported file format versions and IANA time zone name.
 
 ### Bug Fixes
 
diff --git a/Makefile b/Makefile
index 369258c73..6ff4942ac 100644
--- a/Makefile
+++ b/Makefile
@@ -246,6 +246,10 @@ check-import-boundaries: ## Enforce import-boundary invariants for the version-i
 		echo "FAIL: pkg/initerror/ must remain a leaf with zero project-internal imports (test files excepted)"; \
 		bad=1; \
 	fi; \
+	if grep -rln 'github.com/apache/skywalking-banyandb/' pkg/fileformat/*.go 2>/dev/null | grep -v _test.go; then \
+		echo "FAIL: pkg/fileformat/ must remain a leaf with zero project-internal imports (test files excepted)"; \
+		bad=1; \
+	fi; \
 	if [ $$bad -ne 0 ]; then exit 1; fi; \
 	echo "import boundaries OK"
 
diff --git a/api/common/id.go b/api/common/id.go
index 07f2f4913..1152bfeae 100644
--- a/api/common/id.go
+++ b/api/common/id.go
@@ -27,11 +27,14 @@ import (
 
 	"google.golang.org/protobuf/types/known/timestamppb"
 
+	apiversion "github.com/apache/skywalking-banyandb/api/proto/banyandb"
+	clusterv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/cluster/v1"
 	commonv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/common/v1"
 	databasev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/database/v1"
 	modelv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/model/v1"
 	"github.com/apache/skywalking-banyandb/pkg/convert"
 	"github.com/apache/skywalking-banyandb/pkg/encoding"
+	"github.com/apache/skywalking-banyandb/pkg/fileformat"
 	"github.com/apache/skywalking-banyandb/pkg/host"
 )
 
@@ -163,7 +166,10 @@ type Node struct {
 	PropertySchemaGossipGrpcAddress string
 }
 
-// ToProtoNode converts a Node to a databasev1.Node with the given roles.
+// ToProtoNode converts a Node to a databasev1.Node with the given roles. The
+// version and time zone describe the running process, so consumers of the node
+// registry can tell which file formats the node reads and which segment
+// boundaries its timestamps fall on.
 func (n Node) ToProtoNode(roles []databasev1.Role) *databasev1.Node {
 	return &databasev1.Node{
 		Metadata: &commonv1.Metadata{
@@ -177,6 +183,12 @@ func (n Node) ToProtoNode(roles []databasev1.Role) *databasev1.Node {
 		PropertyRepairGossipGrpcAddress: n.PropertyGossipGrpcAddress,
 		PropertySchemaGrpcAddress:       n.PropertySchemaGrpcAddress,
 		PropertySchemaGossipGrpcAddress: n.PropertySchemaGossipGrpcAddress,
+		Version: &clusterv1.VersionInfo{
+			ApiVersion:                  apiversion.Version,
+			FileFormatVersion:           fileformat.CurrentVersion,
+			CompatibleFileFormatVersion: fileformat.CompatibleVersions(),
+		},
+		TzName: host.TimeZoneName(),
 	}
 }
 
diff --git a/api/common/id_test.go b/api/common/id_test.go
new file mode 100644
index 000000000..d314f6f00
--- /dev/null
+++ b/api/common/id_test.go
@@ -0,0 +1,54 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package common
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	apiversion "github.com/apache/skywalking-banyandb/api/proto/banyandb"
+	databasev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/database/v1"
+	"github.com/apache/skywalking-banyandb/pkg/fileformat"
+)
+
+func TestToProtoNodeReportsVersionAndTimeZone(t *testing.T) {
+	t.Setenv("TZ", "Asia/Shanghai")
+	n := Node{NodeID: "10.0.0.5:17912", GrpcAddress: "10.0.0.5:17912"}
+
+	pn := n.ToProtoNode([]databasev1.Role{databasev1.Role_ROLE_DATA})
+
+	require.NotNil(t, pn.Version)
+	assert.Equal(t, apiversion.Version, pn.Version.ApiVersion)
+	assert.Equal(t, fileformat.CurrentVersion, pn.Version.FileFormatVersion)
+	// The gate tests membership in this list, so a node that reports a list its own
+	// version is missing from would reject data it can actually read.
+	assert.Contains(t, pn.Version.CompatibleFileFormatVersion, fileformat.CurrentVersion)
+	assert.Equal(t, "Asia/Shanghai", pn.TzName)
+}
+
+// The gate on the import side rejects a unit when the two sides disagree, so an
+// unresolvable zone has to arrive as an empty name rather than as "Local".
+func TestToProtoNodeLeavesTimeZoneEmptyWhenUnresolvable(t *testing.T) {
+	t.Setenv("TZ", "Mars/Olympus_Mons")
+
+	pn := Node{NodeID: "10.0.0.5:17912"}.ToProtoNode(nil)
+
+	assert.Empty(t, pn.TzName)
+}
diff --git a/api/proto/banyandb/database/v1/database.proto b/api/proto/banyandb/database/v1/database.proto
index a564eb157..881a2edf9 100644
--- a/api/proto/banyandb/database/v1/database.proto
+++ b/api/proto/banyandb/database/v1/database.proto
@@ -19,6 +19,7 @@ syntax = "proto3";
 
 package banyandb.database.v1;
 
+import "banyandb/cluster/v1/rpc.proto";
 import "banyandb/common/v1/common.proto";
 import "google/protobuf/timestamp.proto";
 
@@ -43,6 +44,12 @@ message Node {
   string property_repair_gossip_grpc_address = 7;
   string property_schema_grpc_address = 8;
   string property_schema_gossip_grpc_address = 9;
+  // version reports the file format versions the node accepts and its API version.
+  cluster.v1.VersionInfo version = 10;
+  // tz_name is the IANA name of the node's local time zone, for example
+  // "Asia/Shanghai". It is empty when the node cannot determine the name, which
+  // means "unknown" rather than "matches whatever the other side reports".
+  string tz_name = 11;
 }
 
 message Shard {
diff --git a/banyand/backup/lifecycle/service.go b/banyand/backup/lifecycle/service.go
index dbd8858d6..450dd184f 100644
--- a/banyand/backup/lifecycle/service.go
+++ b/banyand/backup/lifecycle/service.go
@@ -41,7 +41,6 @@ import (
 	"google.golang.org/grpc/credentials/insecure"
 	"google.golang.org/grpc/health"
 	"google.golang.org/grpc/health/grpc_health_v1"
-	"google.golang.org/protobuf/types/known/timestamppb"
 
 	"github.com/apache/skywalking-banyandb/api/common"
 	commonv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/common/v1"
@@ -245,16 +244,14 @@ func (l *lifecycleService) Validate() error {
 				return errors.New("missing key file when TLS is enabled")
 			}
 		}
-		l.currentNode = &databasev1.Node{
-			Metadata: &commonv1.Metadata{
-				Name: l.lifecycleGRPCAddr,
-			},
+		// Built through ToProtoNode so this node reports the same file format
+		// version and time zone as every other one; GetCurrentNode serves it.
+		l.currentNode = common.Node{
+			NodeID:      l.lifecycleGRPCAddr,
 			GrpcAddress: l.lifecycleGRPCAddr,
-			HttpAddress: l.lifecycleHTTPAddr,
-			Roles:       make([]databasev1.Role, 0),
+			HTTPAddress: l.lifecycleHTTPAddr,
 			Labels:      common.ParseNodeFlags(),
-			CreatedAt:   timestamppb.Now(),
-		}
+		}.ToProtoNode(nil)
 	}
 	policy, err := parseOrphanPolicy(l.orphanPolicyStr)
 	if err != nil {
diff --git a/banyand/internal/storage/tzdata.go b/banyand/internal/storage/tzdata.go
new file mode 100644
index 000000000..b6941e1e2
--- /dev/null
+++ b/banyand/internal/storage/tzdata.go
@@ -0,0 +1,39 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//	http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package storage
+
+// The segment grid is a function of time.Local: parseSegmentTime turns a segment
+// directory suffix back into an instant with it. Every binary that touches
+// segments therefore has to resolve the same zone, and it has to resolve the one
+// the operator asked for.
+//
+// Resolving a zone by name needs a zone database, and the minimal images do not
+// ship one -- busybox, alpine and even ubuntu leave out /usr/share/zoneinfo. With
+// no database the Go runtime cannot load the zone TZ names, silently falls back
+// to UTC, and the node then aligns its segments on the wrong grid while reporting
+// no time zone at all. The images this project publishes disagree among
+// themselves about it: the distroless one carries a database, the busybox ones do
+// not, so today a data node and a lifecycle sidecar of the same cluster can read
+// the same segment directory eight hours apart.
+//
+// Embedding the database settles that in the binary, so the answer no longer
+// depends on which base image a binary happens to ship in. It costs roughly
+// 400KiB. It belongs here rather than in a build tag because a tag would leave
+// tests and local builds resolving zones differently from the released image,
+// and this package is linked into every banyand binary but not into bydbctl.
+import _ "time/tzdata"
diff --git a/banyand/internal/storage/version.go b/banyand/internal/storage/version.go
index 29648c261..00071710c 100644
--- a/banyand/internal/storage/version.go
+++ b/banyand/internal/storage/version.go
@@ -18,21 +18,18 @@
 package storage
 
 import (
-	"embed"
 	"encoding/json"
 	"strings"
 
 	"github.com/pkg/errors"
-	"sigs.k8s.io/yaml"
 
+	"github.com/apache/skywalking-banyandb/pkg/fileformat"
 	"github.com/apache/skywalking-banyandb/pkg/initerror"
 )
 
 const (
-	metadataFilename           = "metadata"
-	currentVersion             = "1.5.0"
-	compatibleVersionsKey      = "versions"
-	compatibleVersionsFilename = "versions.yml"
+	metadataFilename = "metadata"
+	currentVersion   = fileformat.CurrentVersion
 )
 
 // SegmentMetadataFilename is the filename used for the per-segment
@@ -56,10 +53,7 @@ type SegmentMetadata struct {
 
 var errVersionIncompatible = errors.New("version not compatible")
 
-var compatibleVersions = readCompatibleVersions()
-
-//go:embed versions.yml
-var versionFS embed.FS
+var compatibleVersions = fileformat.CompatibleVersions()
 
 func checkVersion(version string) error {
 	for _, v := range compatibleVersions {
@@ -92,26 +86,6 @@ func readSegmentMeta(data []byte) (segmentMeta, error) {
 	return meta, nil
 }
 
-func readCompatibleVersions() []string {
-	i, err := versionFS.ReadFile(compatibleVersionsFilename)
-	if err != nil {
-		panic(err)
-	}
-	j, err := yaml.YAMLToJSON(i)
-	if err != nil {
-		panic(err)
-	}
-	var compatibleVersions map[string][]string
-	if err := json.Unmarshal(j, &compatibleVersions); err != nil {
-		panic(err)
-	}
-	vv, ok := compatibleVersions[compatibleVersionsKey]
-	if !ok {
-		panic("versions not found")
-	}
-	return vv
-}
-
 // GetCurrentVersion returns the current storage version.
 func GetCurrentVersion() string {
 	return currentVersion
diff --git a/docs/api-reference.md b/docs/api-reference.md
index a94360ae1..ad83512bc 100644
--- a/docs/api-reference.md
+++ b/docs/api-reference.md
@@ -2952,6 +2952,8 @@ Service is the service for the API
 | property_repair_gossip_grpc_address | [string](#string) |  |  |
 | property_schema_grpc_address | [string](#string) |  |  |
 | property_schema_gossip_grpc_address | [string](#string) |  |  |
+| version | [banyandb.cluster.v1.VersionInfo](#banyandb-cluster-v1-VersionInfo) |  | version reports the file format versions the node accepts and its API version. |
+| tz_name | [string](#string) |  | tz_name is the IANA name of the node&#39;s local time zone, for example &#34;Asia/Shanghai&#34;. It is empty when the node cannot determine the name, which means &#34;unknown&#34; rather than &#34;matches whatever the other side reports&#34;. |
 
 
 
diff --git a/pkg/fileformat/version.go b/pkg/fileformat/version.go
new file mode 100644
index 000000000..29827ff36
--- /dev/null
+++ b/pkg/fileformat/version.go
@@ -0,0 +1,64 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+// Package fileformat holds the on-disk segment file format version line. It is a
+// leaf package so that both the storage engine and the low-level node types in
+// api/common can report the same versions without importing each other.
+package fileformat
+
+import (
+	"embed"
+	"encoding/json"
+	"slices"
+
+	"sigs.k8s.io/yaml"
+)
+
+// CurrentVersion is the version string a freshly-written segment must carry.
+const CurrentVersion = "1.5.0"
+
+//go:embed versions.yml
+var versionFS embed.FS
+
+var compatibleVersions = readCompatibleVersions()
+
+// CompatibleVersions returns every segment file format version this build reads.
+// The result is a copy: callers hand it straight to proto messages, and one of
+// them appending to it would rewrite the list for the whole process.
+func CompatibleVersions() []string {
+	return slices.Clone(compatibleVersions)
+}
+
+func readCompatibleVersions() []string {
+	i, err := versionFS.ReadFile("versions.yml")
+	if err != nil {
+		panic(err)
+	}
+	j, err := yaml.YAMLToJSON(i)
+	if err != nil {
+		panic(err)
+	}
+	var versions map[string][]string
+	if err := json.Unmarshal(j, &versions); err != nil {
+		panic(err)
+	}
+	vv, ok := versions["versions"]
+	if !ok {
+		panic("versions not found")
+	}
+	return vv
+}
diff --git a/pkg/fileformat/version_test.go b/pkg/fileformat/version_test.go
new file mode 100644
index 000000000..f6bd3929e
--- /dev/null
+++ b/pkg/fileformat/version_test.go
@@ -0,0 +1,28 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package fileformat
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestCurrentVersionIsCompatible(t *testing.T) {
+	assert.Contains(t, CompatibleVersions(), CurrentVersion)
+}
diff --git a/banyand/internal/storage/versions.yml b/pkg/fileformat/versions.yml
similarity index 100%
rename from banyand/internal/storage/versions.yml
rename to pkg/fileformat/versions.yml
diff --git a/pkg/host/testdata/abidjan.tzif b/pkg/host/testdata/abidjan.tzif
new file mode 100644
index 000000000..28b32ab2e
Binary files /dev/null and b/pkg/host/testdata/abidjan.tzif differ
diff --git a/pkg/host/testdata/shanghai.tzif b/pkg/host/testdata/shanghai.tzif
new file mode 100644
index 000000000..e69de29bb
diff --git a/pkg/host/testdata/utc.tzif b/pkg/host/testdata/utc.tzif
new file mode 100644
index 000000000..91558be0c
Binary files /dev/null and b/pkg/host/testdata/utc.tzif differ
diff --git a/pkg/host/timezone.go b/pkg/host/timezone.go
new file mode 100644
index 000000000..226c7eb10
--- /dev/null
+++ b/pkg/host/timezone.go
@@ -0,0 +1,33 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package host
+
+// TimeZoneName returns the IANA name of the host's local time zone, such as
+// "Asia/Shanghai", or an empty string when the real name cannot be determined.
+// An empty name means "unknown": callers comparing two hosts must treat it as a
+// mismatch rather than as agreement.
+//
+// There is no portable way to ask for this. The Go runtime resolves the local
+// zone but never exposes its name — time.Local.String() reports the literal
+// "Local" whenever TZ is unset, which is the normal state inside a container,
+// so two hosts in different zones would look identical. Each platform therefore
+// resolves the name the same way its own runtime resolves the zone: see
+// localZoneName in timezone_unix.go and timezone_windows.go.
+func TimeZoneName() string {
+	return localZoneName()
+}
diff --git a/pkg/host/timezone_cldr_windows.go b/pkg/host/timezone_cldr_windows.go
new file mode 100644
index 000000000..d024ca781
--- /dev/null
+++ b/pkg/host/timezone_cldr_windows.go
@@ -0,0 +1,168 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package host
+
+// windowsToIANA maps a Windows time zone ID, as the registry reports it, to the
+// IANA name CLDR designates as its default. Windows has no IANA zones of its
+// own, so this table is the only way to answer with a name the rest of the
+// cluster understands.
+//
+// Extracted from the territory="001" rows of the Unicode CLDR
+// supplemental/windowsZones.xml, typeVersion 2021a. Refresh it from that file
+// rather than by hand.
+var windowsToIANA = map[string]string{
+	"AUS Central Standard Time":       "Australia/Darwin",
+	"AUS Eastern Standard Time":       "Australia/Sydney",
+	"Afghanistan Standard Time":       "Asia/Kabul",
+	"Alaskan Standard Time":           "America/Anchorage",
+	"Aleutian Standard Time":          "America/Adak",
+	"Altai Standard Time":             "Asia/Barnaul",
+	"Arab Standard Time":              "Asia/Riyadh",
+	"Arabian Standard Time":           "Asia/Dubai",
+	"Arabic Standard Time":            "Asia/Baghdad",
+	"Argentina Standard Time":         "America/Buenos_Aires",
+	"Astrakhan Standard Time":         "Europe/Astrakhan",
+	"Atlantic Standard Time":          "America/Halifax",
+	"Aus Central W. Standard Time":    "Australia/Eucla",
+	"Azerbaijan Standard Time":        "Asia/Baku",
+	"Azores Standard Time":            "Atlantic/Azores",
+	"Bahia Standard Time":             "America/Bahia",
+	"Bangladesh Standard Time":        "Asia/Dhaka",
+	"Belarus Standard Time":           "Europe/Minsk",
+	"Bougainville Standard Time":      "Pacific/Bougainville",
+	"Canada Central Standard Time":    "America/Regina",
+	"Cape Verde Standard Time":        "Atlantic/Cape_Verde",
+	"Caucasus Standard Time":          "Asia/Yerevan",
+	"Cen. Australia Standard Time":    "Australia/Adelaide",
+	"Central America Standard Time":   "America/Guatemala",
+	"Central Asia Standard Time":      "Asia/Bishkek",
+	"Central Brazilian Standard Time": "America/Cuiaba",
+	"Central Europe Standard Time":    "Europe/Budapest",
+	"Central European Standard Time":  "Europe/Warsaw",
+	"Central Pacific Standard Time":   "Pacific/Guadalcanal",
+	"Central Standard Time":           "America/Chicago",
+	"Central Standard Time (Mexico)":  "America/Mexico_City",
+	"Chatham Islands Standard Time":   "Pacific/Chatham",
+	"China Standard Time":             "Asia/Shanghai",
+	"Cuba Standard Time":              "America/Havana",
+	"Dateline Standard Time":          "Etc/GMT+12",
+	"E. Africa Standard Time":         "Africa/Nairobi",
+	"E. Australia Standard Time":      "Australia/Brisbane",
+	"E. Europe Standard Time":         "Europe/Chisinau",
+	"E. South America Standard Time":  "America/Sao_Paulo",
+	"Easter Island Standard Time":     "Pacific/Easter",
+	"Eastern Standard Time":           "America/New_York",
+	"Eastern Standard Time (Mexico)":  "America/Cancun",
+	"Egypt Standard Time":             "Africa/Cairo",
+	"Ekaterinburg Standard Time":      "Asia/Yekaterinburg",
+	"FLE Standard Time":               "Europe/Kiev",
+	"Fiji Standard Time":              "Pacific/Fiji",
+	"GMT Standard Time":               "Europe/London",
+	"GTB Standard Time":               "Europe/Bucharest",
+	"Georgian Standard Time":          "Asia/Tbilisi",
+	"Greenland Standard Time":         "America/Godthab",
+	"Greenwich Standard Time":         "Atlantic/Reykjavik",
+	"Haiti Standard Time":             "America/Port-au-Prince",
+	"Hawaiian Standard Time":          "Pacific/Honolulu",
+	"India Standard Time":             "Asia/Calcutta",
+	"Iran Standard Time":              "Asia/Tehran",
+	"Israel Standard Time":            "Asia/Jerusalem",
+	"Jordan Standard Time":            "Asia/Amman",
+	"Kaliningrad Standard Time":       "Europe/Kaliningrad",
+	"Korea Standard Time":             "Asia/Seoul",
+	"Libya Standard Time":             "Africa/Tripoli",
+	"Line Islands Standard Time":      "Pacific/Kiritimati",
+	"Lord Howe Standard Time":         "Australia/Lord_Howe",
+	"Magadan Standard Time":           "Asia/Magadan",
+	"Magallanes Standard Time":        "America/Punta_Arenas",
+	"Marquesas Standard Time":         "Pacific/Marquesas",
+	"Mauritius Standard Time":         "Indian/Mauritius",
+	"Middle East Standard Time":       "Asia/Beirut",
+	"Montevideo Standard Time":        "America/Montevideo",
+	"Morocco Standard Time":           "Africa/Casablanca",
+	"Mountain Standard Time":          "America/Denver",
+	"Mountain Standard Time (Mexico)": "America/Mazatlan",
+	"Myanmar Standard Time":           "Asia/Rangoon",
+	"N. Central Asia Standard Time":   "Asia/Novosibirsk",
+	"Namibia Standard Time":           "Africa/Windhoek",
+	"Nepal Standard Time":             "Asia/Katmandu",
+	"New Zealand Standard Time":       "Pacific/Auckland",
+	"Newfoundland Standard Time":      "America/St_Johns",
+	"Norfolk Standard Time":           "Pacific/Norfolk",
+	"North Asia East Standard Time":   "Asia/Irkutsk",
+	"North Asia Standard Time":        "Asia/Krasnoyarsk",
+	"North Korea Standard Time":       "Asia/Pyongyang",
+	"Omsk Standard Time":              "Asia/Omsk",
+	"Pacific SA Standard Time":        "America/Santiago",
+	"Pacific Standard Time":           "America/Los_Angeles",
+	"Pacific Standard Time (Mexico)":  "America/Tijuana",
+	"Pakistan Standard Time":          "Asia/Karachi",
+	"Paraguay Standard Time":          "America/Asuncion",
+	"Qyzylorda Standard Time":         "Asia/Qyzylorda",
+	"Romance Standard Time":           "Europe/Paris",
+	"Russia Time Zone 10":             "Asia/Srednekolymsk",
+	"Russia Time Zone 11":             "Asia/Kamchatka",
+	"Russia Time Zone 3":              "Europe/Samara",
+	"Russian Standard Time":           "Europe/Moscow",
+	"SA Eastern Standard Time":        "America/Cayenne",
+	"SA Pacific Standard Time":        "America/Bogota",
+	"SA Western Standard Time":        "America/La_Paz",
+	"SE Asia Standard Time":           "Asia/Bangkok",
+	"Saint Pierre Standard Time":      "America/Miquelon",
+	"Sakhalin Standard Time":          "Asia/Sakhalin",
+	"Samoa Standard Time":             "Pacific/Apia",
+	"Sao Tome Standard Time":          "Africa/Sao_Tome",
+	"Saratov Standard Time":           "Europe/Saratov",
+	"Singapore Standard Time":         "Asia/Singapore",
+	"South Africa Standard Time":      "Africa/Johannesburg",
+	"South Sudan Standard Time":       "Africa/Juba",
+	"Sri Lanka Standard Time":         "Asia/Colombo",
+	"Sudan Standard Time":             "Africa/Khartoum",
+	"Syria Standard Time":             "Asia/Damascus",
+	"Taipei Standard Time":            "Asia/Taipei",
+	"Tasmania Standard Time":          "Australia/Hobart",
+	"Tocantins Standard Time":         "America/Araguaina",
+	"Tokyo Standard Time":             "Asia/Tokyo",
+	"Tomsk Standard Time":             "Asia/Tomsk",
+	"Tonga Standard Time":             "Pacific/Tongatapu",
+	"Transbaikal Standard Time":       "Asia/Chita",
+	"Turkey Standard Time":            "Europe/Istanbul",
+	"Turks And Caicos Standard Time":  "America/Grand_Turk",
+	"US Eastern Standard Time":        "America/Indianapolis",
+	"US Mountain Standard Time":       "America/Phoenix",
+	"UTC":                             "Etc/UTC",
+	"UTC+12":                          "Etc/GMT-12",
+	"UTC+13":                          "Etc/GMT-13",
+	"UTC-02":                          "Etc/GMT+2",
+	"UTC-08":                          "Etc/GMT+8",
+	"UTC-09":                          "Etc/GMT+9",
+	"UTC-11":                          "Etc/GMT+11",
+	"Ulaanbaatar Standard Time":       "Asia/Ulaanbaatar",
+	"Venezuela Standard Time":         "America/Caracas",
+	"Vladivostok Standard Time":       "Asia/Vladivostok",
+	"Volgograd Standard Time":         "Europe/Volgograd",
+	"W. Australia Standard Time":      "Australia/Perth",
+	"W. Central Africa Standard Time": "Africa/Lagos",
+	"W. Europe Standard Time":         "Europe/Berlin",
+	"W. Mongolia Standard Time":       "Asia/Hovd",
+	"West Asia Standard Time":         "Asia/Tashkent",
+	"West Bank Standard Time":         "Asia/Hebron",
+	"West Pacific Standard Time":      "Pacific/Port_Moresby",
+	"Yakutsk Standard Time":           "Asia/Yakutsk",
+	"Yukon Standard Time":             "America/Whitehorse",
+}
diff --git a/pkg/host/timezone_unix.go b/pkg/host/timezone_unix.go
new file mode 100644
index 000000000..66a9f1f10
--- /dev/null
+++ b/pkg/host/timezone_unix.go
@@ -0,0 +1,138 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//go:build !windows
+
+package host
+
+import (
+	"os"
+	"path/filepath"
+	"strings"
+	"time"
+)
+
+// localtimePath is the symlink the Go runtime itself consults when TZ is unset.
+// It is a variable so tests can point it at a fixture.
+var localtimePath = "/etc/localtime"
+
+// runtimeZoneName reports the name the Go runtime gave the local zone. It is a
+// variable so tests can drive the branch that reads it without depending on the
+// zone the test runner happens to be in.
+var runtimeZoneName = time.Local.String
+
+// zoneinfoMarker is the last path element shared by every zoneinfo database
+// layout: /usr/share/zoneinfo on Linux, /var/db/timezone/zoneinfo on macOS.
+const zoneinfoMarker = "zoneinfo"
+
+// A zone that is UTC now but not always is not UTC. January and July of every
+// year in this range catch both daylight saving and the historical offset
+// changes that every non-UTC zone carries.
+const (
+	firstProbeYear = 1970
+	lastProbeYear  = 2038
+)
+
+// localZoneName reads the zone the way the Go runtime does on this platform:
+// TZ when it is set, otherwise the /etc/localtime link.
+func localZoneName() string {
+	if tz, ok := os.LookupEnv("TZ"); ok {
+		// Read TZ the way the Go runtime does: an empty value means UTC, a leading
+		// colon is stripped, and an absolute path names a zone file rather than a
+		// zone, so the name has to be recovered from the path.
+		name := strings.TrimPrefix(tz, ":")
+		if name == "" {
+			return "UTC"
+		}
+		if strings.HasPrefix(name, "/") {
+			name = zoneNameFromPath(name)
+		}
+		return validZoneName(name)
+	}
+	// EvalSymlinks rather than Readlink: it follows a chain of links, and on a
+	// plain file it yields that file's own path, which carries no zone name.
+	if target, err := filepath.EvalSymlinks(localtimePath); err == nil {
+		if name := validZoneName(zoneNameFromPath(target)); name != "" {
+			return name
+		}
+	}
+	// No name anywhere in the path. Container images copy the zone file instead of
+	// linking it, so the next thing to read is the file's own contents.
+	if name := utcZoneName(localtimePath); name != "" {
+		return name
+	}
+	// Distroless images ship no /etc/localtime at all, so nothing on disk can
+	// answer. The runtime resolved a zone anyway, and initLocal names it "UTC"
+	// only on the branches where it gave up and used UTC — a zone it loaded by
+	// name keeps that name, and one loaded from /etc/localtime is called "Local".
+	// So when this reports UTC, UTC is what the process is really running.
+	if runtimeZoneName() == "UTC" {
+		return "UTC"
+	}
+	return ""
+}
+
+// utcZoneName reports "UTC" when the zone file at path is the UTC zone, and an
+// empty string otherwise.
+//
+// A copied zone file carries rules but no name, and rules cannot be reversed
+// into an IANA name in general because several zones share them. UTC is the one
+// case worth resolving: it is what the stock container images put in
+// /etc/localtime, and it is the zone whose offset is zero and whose abbreviation
+// is "UTC" at every instant — Africa/Abidjan is also permanently UTC+0 but calls
+// itself GMT, so the abbreviation keeps the two apart.
+func utcZoneName(path string) string {
+	data, err := os.ReadFile(path)
+	if err != nil {
+		return ""
+	}
+	loc, err := time.LoadLocationFromTZData("", data)
+	if err != nil {
+		return ""
+	}
+	for year := firstProbeYear; year <= lastProbeYear; year++ {
+		for _, month := range [...]time.Month{time.January, time.July} {
+			name, offset := time.Date(year, month, 1, 0, 0, 0, 0, time.UTC).In(loc).Zone()
+			if offset != 0 || name != "UTC" {
+				return ""
+			}
+		}
+	}
+	return "UTC"
+}
+
+// zoneNameFromPath extracts the part of a zoneinfo file path that follows the
+// zoneinfo directory, which is exactly the IANA name.
+func zoneNameFromPath(path string) string {
+	segments := strings.Split(filepath.Clean(path), "/")
+	for i := len(segments) - 1; i >= 0; i-- {
+		if segments[i] == zoneinfoMarker {
+			return strings.Join(segments[i+1:], "/")
+		}
+	}
+	return ""
+}
+
+func validZoneName(name string) string {
+	if name == "" {
+		return ""
+	}
+	if _, err := time.LoadLocation(name); err != nil {
+		return ""
+	}
+	return name
+}
diff --git a/pkg/host/timezone_unix_test.go b/pkg/host/timezone_unix_test.go
new file mode 100644
index 000000000..be13f9f45
--- /dev/null
+++ b/pkg/host/timezone_unix_test.go
@@ -0,0 +1,202 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//go:build !windows
+
+package host
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestTimeZoneNameFromEnv(t *testing.T) {
+	tests := []struct {
+		name string
+		tz   string
+		want string
+	}{
+		{name: "iana name", tz: "Asia/Shanghai", want: "Asia/Shanghai"},
+		{name: "leading colon", tz: ":Europe/Paris", want: "Europe/Paris"},
+		{name: "utc", tz: "UTC", want: "UTC"},
+		{name: "empty means utc", tz: "", want: "UTC"},
+		{name: "absolute path names a zone file", tz: "/usr/share/zoneinfo/Asia/Tokyo", want: "Asia/Tokyo"},
+		{name: "colon before absolute path", tz: ":/var/db/timezone/zoneinfo/Europe/Paris", want: "Europe/Paris"},
+		// A POSIX offset string is not an IANA name, and the runtime silently falls
+		// back to UTC for it, so the node must not claim the zone it spells out.
+		{name: "posix offset yields no claim", tz: "CST-8", want: ""},
+		{name: "unknown name yields no claim", tz: "Mars/Olympus_Mons", want: ""},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			t.Setenv("TZ", tt.tz)
+			assert.Equal(t, tt.want, TimeZoneName())
+		})
+	}
+}
+
+func TestTimeZoneNameFromLocaltime(t *testing.T) {
+	tests := []struct {
+		name     string
+		zonePath string
+		chained  bool
+		plain    bool
+		want     string
+	}{
+		{name: "linux layout", zonePath: "usr/share/zoneinfo/Asia/Shanghai", want: "Asia/Shanghai"},
+		{name: "macos layout", zonePath: "var/db/timezone/zoneinfo/Europe/Paris", want: "Europe/Paris"},
+		{name: "nested region", zonePath: "usr/share/zoneinfo/America/Argentina/Buenos_Aires", want: "America/Argentina/Buenos_Aires"},
+		{name: "chain of symlinks", zonePath: "usr/share/zoneinfo/America/New_York", chained: true, want: "America/New_York"},
+		{name: "plain file outside zoneinfo", zonePath: "etc/localtime", plain: true, want: ""},
+		// A copy that happens to sit under a zoneinfo tree still names its zone.
+		{name: "plain file under zoneinfo", zonePath: "usr/share/zoneinfo/Asia/Shanghai", plain: true, want: "Asia/Shanghai"},
+		{name: "link outside zoneinfo", zonePath: "etc/localtime-copy", want: ""},
+		{name: "unknown zone", zonePath: "usr/share/zoneinfo/Mars/Olympus_Mons", want: ""},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			withoutTZ(t)
+			root := t.TempDir()
+			target := filepath.Join(root, tt.zonePath)
+			require.NoError(t, os.MkdirAll(filepath.Dir(target), 0o750))
+			require.NoError(t, os.WriteFile(target, nil, 0o600))
+			if tt.plain {
+				withLocaltimePath(t, target)
+				assert.Equal(t, tt.want, TimeZoneName())
+				return
+			}
+			if tt.chained {
+				mid := filepath.Join(root, "middle-link")
+				require.NoError(t, os.Symlink(target, mid))
+				target = mid
+			}
+			link := filepath.Join(root, "localtime")
+			require.NoError(t, os.Symlink(target, link))
+			withLocaltimePath(t, link)
+			assert.Equal(t, tt.want, TimeZoneName())
+		})
+	}
+}
+
+// busybox and friends copy the zone data into /etc/localtime instead of linking
+// it, so nothing in the path names the zone and the contents are all that is
+// left to read. The file every stock image ships is UTC, and saying so is what
+// lets a default deployment answer at all.
+func TestTimeZoneNameFromLocaltimeContents(t *testing.T) {
+	tests := []struct {
+		name    string
+		fixture string
+		want    string
+	}{
+		{name: "utc zone file", fixture: "utc.tzif", want: "UTC"},
+		// Permanently UTC+0 but named GMT, so the offset alone must not decide it.
+		{name: "abidjan is not utc", fixture: "abidjan.tzif", want: ""},
+		{name: "shanghai", fixture: "shanghai.tzif", want: ""},
+		{name: "not a zone file", fixture: "", want: ""},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			withoutTZ(t)
+			target := filepath.Join(t.TempDir(), "localtime")
+			var data []byte
+			if tt.fixture == "" {
+				data = []byte("not a zone file")
+			} else {
+				var err error
+				data, err = os.ReadFile(filepath.Join("testdata", tt.fixture))
+				require.NoError(t, err)
+			}
+			require.NoError(t, os.WriteFile(target, data, 0o600))
+			withLocaltimePath(t, target)
+			assert.Equal(t, tt.want, TimeZoneName())
+		})
+	}
+}
+
+// The zone file reached through a symlink is read the same way, so an image that
+// links /etc/localtime at a path with no zoneinfo element still answers.
+func TestTimeZoneNameFromSymlinkedUTCFile(t *testing.T) {
+	withoutTZ(t)
+	root := t.TempDir()
+	target := filepath.Join(root, "utc-copy")
+	data, err := os.ReadFile(filepath.Join("testdata", "utc.tzif"))
+	require.NoError(t, err)
+	require.NoError(t, os.WriteFile(target, data, 0o600))
+	link := filepath.Join(root, "localtime")
+	require.NoError(t, os.Symlink(target, link))
+	withLocaltimePath(t, link)
+	assert.Equal(t, "UTC", TimeZoneName())
+}
+
+// A container without TZ and without /etc/localtime must report no name at all:
+// reporting "Local" here would make two nodes in different zones compare equal.
+func TestTimeZoneNameUnresolvable(t *testing.T) {
+	withoutTZ(t)
+	withLocaltimePath(t, filepath.Join(t.TempDir(), "absent"))
+	assert.Empty(t, TimeZoneName())
+}
+
+// Distroless images carry no /etc/localtime, so the runtime's own fallback is the
+// only thing left that knows the process is on UTC.
+func TestTimeZoneNameFromRuntimeFallback(t *testing.T) {
+	tests := []struct {
+		name        string
+		runtimeZone string
+		want        string
+	}{
+		{name: "runtime fell back to utc", runtimeZone: "UTC", want: "UTC"},
+		// "Local" means the runtime loaded a zone file it cannot name, which is not
+		// a claim this can turn into an IANA name.
+		{name: "runtime says local", runtimeZone: "Local", want: ""},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			withoutTZ(t)
+			withRuntimeZone(t, tt.runtimeZone)
+			withLocaltimePath(t, filepath.Join(t.TempDir(), "absent"))
+			assert.Equal(t, tt.want, TimeZoneName())
+		})
+	}
+}
+
+func withoutTZ(t *testing.T) {
+	t.Helper()
+	// t.Setenv registers the restore; Unsetenv afterwards leaves TZ unset for the test.
+	t.Setenv("TZ", "")
+	require.NoError(t, os.Unsetenv("TZ"))
+	// Pin the runtime's own answer so a case that expects "unknown" cannot be
+	// rescued by the runner happening to sit in UTC.
+	withRuntimeZone(t, "Local")
+}
+
+func withRuntimeZone(t *testing.T, name string) {
+	t.Helper()
+	original := runtimeZoneName
+	runtimeZoneName = func() string { return name }
+	t.Cleanup(func() { runtimeZoneName = original })
+}
+
+func withLocaltimePath(t *testing.T, path string) {
+	t.Helper()
+	original := localtimePath
+	localtimePath = path
+	t.Cleanup(func() { localtimePath = original })
+}
diff --git a/pkg/host/timezone_windows.go b/pkg/host/timezone_windows.go
new file mode 100644
index 000000000..87059717f
--- /dev/null
+++ b/pkg/host/timezone_windows.go
@@ -0,0 +1,51 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package host
+
+import (
+	"golang.org/x/sys/windows/registry"
+)
+
+// tzInfoKey is where Windows records the zone the whole machine runs on. It is a
+// variable so tests can point it at a fixture key.
+var tzInfoKey = `SYSTEM\CurrentControlSet\Control\TimeZoneInformation`
+
+// localZoneName reads the Windows zone ID from the registry and translates it to
+// an IANA name.
+//
+// It deliberately ignores TZ: the Go runtime's initLocal on Windows calls
+// GetTimeZoneInformation and never looks at TZ, so honouring it here would name
+// a zone the process is not actually running in. It also does not validate the
+// result with time.LoadLocation, because Windows ships no zoneinfo database —
+// the CLDR table is what makes a Windows ID mean exactly one IANA zone.
+func localZoneName() string {
+	k, err := registry.OpenKey(registry.LOCAL_MACHINE, tzInfoKey, registry.QUERY_VALUE)
+	if err != nil {
+		return ""
+	}
+	defer func() {
+		_ = k.Close()
+	}()
+	// TimeZoneKeyName is the invariant zone ID. StandardName sits next to it but is
+	// localised, so it cannot be looked up in the CLDR table.
+	id, _, err := k.GetStringValue("TimeZoneKeyName")
+	if err != nil {
+		return ""
+	}
+	return windowsToIANA[id]
+}
diff --git a/test/e2e-v2/cases/cluster/e2e.yaml b/test/e2e-v2/cases/cluster/e2e.yaml
index ec71dc49a..9338cefd5 100644
--- a/test/e2e-v2/cases/cluster/e2e.yaml
+++ b/test/e2e-v2/cases/cluster/e2e.yaml
@@ -46,6 +46,8 @@ verify:
     # the interval between two retries, in millisecond.
     interval: 10s
   cases:
+  - query: curl -s --max-time 10 http://127.0.0.1:17913/api/v1/cluster/state
+    expected: expected/node-info.yml
   - includes:
     - ../storage/storage-cases.yaml
 
diff --git a/test/e2e-v2/cases/cluster/expected/node-info.yml b/test/e2e-v2/cases/cluster/expected/node-info.yml
new file mode 100644
index 000000000..fbc1616f2
--- /dev/null
+++ b/test/e2e-v2/cases/cluster/expected/node-info.yml
@@ -0,0 +1,71 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+routeTables:
+  tire1:
+    registered:
+      - metadata:
+          group: ""
+          name: liaison:18912
+          id: 0
+          createRevision: "0"
+          modRevision: "0"
+        roles:
+          - ROLE_LIAISON
+        grpcAddress: liaison:18912
+        httpAddress: liaison:17913
+        createdAt: {{ notEmpty (index . "routeTables" "tire1" "registered" 0 "createdAt") }}
+        labels: {}
+        propertyRepairGossipGrpcAddress: ""
+        propertySchemaGrpcAddress: ""
+        propertySchemaGossipGrpcAddress: ""
+        version:
+          fileFormatVersion: "1.5.0"
+          compatibleFileFormatVersion:
+            - "1.4.0"
+            - "1.5.0"
+          apiVersion: "0.11"
+        tzName: {{ notEmpty (index . "routeTables" "tire1" "registered" 0 "tzName") }}
+    active:
+      - liaison:18912
+    evictable: []
+  tire2:
+    registered:
+      - metadata:
+          group: ""
+          name: data:17912
+          id: 0
+          createRevision: "0"
+          modRevision: "0"
+        roles:
+          - ROLE_META
+          - ROLE_DATA
+        grpcAddress: data:17912
+        httpAddress: ""
+        createdAt: {{ notEmpty (index . "routeTables" "tire2" "registered" 0 "createdAt") }}
+        labels: {}
+        propertyRepairGossipGrpcAddress: data:17932
+        propertySchemaGrpcAddress: data:17916
+        propertySchemaGossipGrpcAddress: ""
+        version:
+          fileFormatVersion: "1.5.0"
+          compatibleFileFormatVersion:
+            - "1.4.0"
+            - "1.5.0"
+          apiVersion: "0.11"
+        tzName: {{ notEmpty (index . "routeTables" "tire2" "registered" 0 "tzName") }}
+    active:
+      - data:17912
+    evictable: []
diff --git a/test/integration/distributed/cluster_state/common.go b/test/integration/distributed/cluster_state/common.go
index e8ba6c5dd..aa1927000 100644
--- a/test/integration/distributed/cluster_state/common.go
+++ b/test/integration/distributed/cluster_state/common.go
@@ -28,8 +28,11 @@ import (
 	"google.golang.org/grpc"
 	"google.golang.org/grpc/credentials/insecure"
 
+	apiversion "github.com/apache/skywalking-banyandb/api/proto/banyandb"
 	databasev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/database/v1"
+	"github.com/apache/skywalking-banyandb/pkg/fileformat"
 	"github.com/apache/skywalking-banyandb/pkg/grpchelper"
+	"github.com/apache/skywalking-banyandb/pkg/host"
 	"github.com/apache/skywalking-banyandb/pkg/logger"
 	"github.com/apache/skywalking-banyandb/pkg/test"
 	"github.com/apache/skywalking-banyandb/pkg/test/flags"
@@ -86,6 +89,44 @@ var _ = ginkgo.Describe("ClusterState API", func() {
 		gomega.Expect(state.GetRouteTables()).To(gomega.HaveKey("tire1"))
 		gomega.Expect(state.GetRouteTables()).To(gomega.HaveKey("tire2"))
 	})
+
+	// The import gates read the file format version and the time zone of every
+	// data node out of this one call, so the fields have to survive registration
+	// and show up on the liaison's tire2 table, not just on the node itself.
+	ginkgo.It("Report the file format version and the time zone of each data node", func() {
+		client := databasev1.NewClusterStateServiceClient(liaisonConnection)
+		var dataNodes []*databasev1.Node
+		gomega.Eventually(func(g gomega.Gomega) {
+			state, err := client.GetClusterState(context.Background(), &databasev1.GetClusterStateRequest{})
+			g.Expect(err).NotTo(gomega.HaveOccurred())
+			dataNodes = state.GetRouteTables()["tire2"].GetRegistered()
+			g.Expect(dataNodes).NotTo(gomega.BeEmpty())
+		}, flags.EventuallyTimeout).Should(gomega.Succeed())
+		for _, n := range dataNodes {
+			name := n.GetMetadata().GetName()
+			gomega.Expect(n.GetRoles()).To(gomega.ContainElement(databasev1.Role_ROLE_DATA), "node %s in tire2 is not a data node", name)
+			gomega.Expect(n.GetVersion()).NotTo(gomega.BeNil(), "node %s reports no version", name)
+			gomega.Expect(n.GetVersion().GetFileFormatVersion()).To(gomega.Equal(fileformat.CurrentVersion))
+			gomega.Expect(n.GetVersion().GetCompatibleFileFormatVersion()).To(gomega.ContainElement(fileformat.CurrentVersion))
+			gomega.Expect(n.GetVersion().GetApiVersion()).To(gomega.Equal(apiversion.Version))
+			// An unresolved zone would make both sides empty and the comparison
+			// vacuous, so require a name first. CI pins the zone per matrix entry.
+			gomega.Expect(n.GetTzName()).NotTo(gomega.BeEmpty(), "node %s resolved no time zone; set TZ or /etc/localtime", name)
+			gomega.Expect(n.GetTzName()).To(gomega.Equal(host.TimeZoneName()))
+		}
+	})
+
+	// GetCurrentNode answers for the liaison itself, and it carries the same two
+	// fields because every node type goes through ToProtoNode.
+	ginkgo.It("Report the file format version and the time zone on the current node", func() {
+		client := databasev1.NewNodeQueryServiceClient(liaisonConnection)
+		resp, err := client.GetCurrentNode(context.Background(), &databasev1.GetCurrentNodeRequest{})
+		gomega.Expect(err).NotTo(gomega.HaveOccurred())
+		gomega.Expect(resp.GetNode().GetVersion().GetFileFormatVersion()).To(gomega.Equal(fileformat.CurrentVersion))
+		gomega.Expect(resp.GetNode().GetVersion().GetApiVersion()).To(gomega.Equal(apiversion.Version))
+		gomega.Expect(resp.GetNode().GetTzName()).NotTo(gomega.BeEmpty())
+		gomega.Expect(resp.GetNode().GetTzName()).To(gomega.Equal(host.TimeZoneName()))
+	})
 })
 
 var _ = ginkgo.SynchronizedAfterSuite(func() {
diff --git a/test/integration/standalone/other/node_info_test.go b/test/integration/standalone/other/node_info_test.go
new file mode 100644
index 000000000..c39d4f782
--- /dev/null
+++ b/test/integration/standalone/other/node_info_test.go
@@ -0,0 +1,79 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package other
+
+import (
+	"context"
+	"time"
+
+	g "github.com/onsi/ginkgo/v2"
+	gm "github.com/onsi/gomega"
+	"google.golang.org/grpc"
+	"google.golang.org/grpc/credentials/insecure"
+
+	apiversion "github.com/apache/skywalking-banyandb/api/proto/banyandb"
+	databasev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/database/v1"
+	"github.com/apache/skywalking-banyandb/pkg/fileformat"
+	"github.com/apache/skywalking-banyandb/pkg/grpchelper"
+	"github.com/apache/skywalking-banyandb/pkg/host"
+	"github.com/apache/skywalking-banyandb/pkg/test/setup"
+)
+
+// A standalone process registers no route table, so GetClusterState comes back
+// empty and a client has to read the node's capabilities off GetCurrentNode
+// instead. That only works if the single process reports both roles along with
+// its file format version and time zone.
+var _ = g.Describe("Current node", func() {
+	var conn *grpc.ClientConn
+	var deferFn func()
+
+	g.BeforeEach(func() {
+		var addr string
+		addr, _, deferFn = setup.Standalone(NewTestConfig())
+		var err error
+		conn, err = grpchelper.Conn(addr, 10*time.Second,
+			grpc.WithTransportCredentials(insecure.NewCredentials()))
+		gm.Expect(err).NotTo(gm.HaveOccurred())
+	})
+
+	g.AfterEach(func() {
+		gm.Expect(conn.Close()).To(gm.Succeed())
+		deferFn()
+	})
+
+	g.It("reports the file format version and the time zone", func() {
+		resp, err := databasev1.NewNodeQueryServiceClient(conn).
+			GetCurrentNode(context.Background(), &databasev1.GetCurrentNodeRequest{})
+		gm.Expect(err).NotTo(gm.HaveOccurred())
+		node := resp.GetNode()
+		gm.Expect(node.GetRoles()).To(gm.ContainElements(databasev1.Role_ROLE_LIAISON, databasev1.Role_ROLE_DATA))
+		gm.Expect(node.GetVersion()).NotTo(gm.BeNil())
+		gm.Expect(node.GetVersion().GetFileFormatVersion()).To(gm.Equal(fileformat.CurrentVersion))
+		gm.Expect(node.GetVersion().GetCompatibleFileFormatVersion()).To(gm.ContainElement(fileformat.CurrentVersion))
+		gm.Expect(node.GetVersion().GetApiVersion()).To(gm.Equal(apiversion.Version))
+		gm.Expect(node.GetTzName()).NotTo(gm.BeEmpty())
+		gm.Expect(node.GetTzName()).To(gm.Equal(host.TimeZoneName()))
+	})
+
+	g.It("returns an empty route table so the client falls back to the current node", func() {
+		resp, err := databasev1.NewClusterStateServiceClient(conn).
+			GetClusterState(context.Background(), &databasev1.GetClusterStateRequest{})
+		gm.Expect(err).NotTo(gm.HaveOccurred())
+		gm.Expect(resp.GetRouteTables()).To(gm.BeEmpty())
+	})
+})