diff --git a/devenv/docker-compose.yml b/devenv/docker-compose.yml index db202367..166de4f8 100644 --- a/devenv/docker-compose.yml +++ b/devenv/docker-compose.yml @@ -7,9 +7,9 @@ services: POSTGRES_DB: memoh POSTGRES_USER: memoh POSTGRES_PASSWORD: memoh123 - TZ: ${TZ:-UTC} volumes: - postgres_data:/var/lib/postgresql/data + - /etc/localtime:/etc/localtime:ro ports: - "5432:5432" healthcheck: diff --git a/docker-compose.yml b/docker-compose.yml index 2303ab54..b94986a6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,9 +7,9 @@ services: POSTGRES_DB: memoh POSTGRES_USER: memoh POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-memoh123} - TZ: ${TZ:-UTC} volumes: - postgres_data:/var/lib/postgresql/data + - /etc/localtime:/etc/localtime:ro expose: - "5432" healthcheck: @@ -47,8 +47,6 @@ services: - COMMIT_HASH=${MEMOH_COMMIT:-unknown} - BUILD_TIME=${MEMOH_BUILD_TIME:-unknown} container_name: memoh-migrate - environment: - TZ: ${TZ:-UTC} entrypoint: ["/app/memoh-server", "migrate", "up"] volumes: - ${MEMOH_CONFIG:-./conf/app.docker.toml}:/app/config.toml:ro @@ -70,13 +68,12 @@ services: container_name: memoh-server privileged: true pid: host - environment: - TZ: ${TZ:-UTC} volumes: - ${MEMOH_CONFIG:-./conf/app.docker.toml}:/app/config.toml:ro - containerd_data:/var/lib/containerd - server_cni_state:/var/lib/cni - memoh_data:/opt/memoh/data + - /etc/localtime:/etc/localtime:ro ports: - "8080:8080" depends_on: @@ -93,10 +90,9 @@ services: context: . dockerfile: docker/Dockerfile.agent container_name: memoh-agent - environment: - TZ: ${TZ:-UTC} volumes: - ${MEMOH_CONFIG:-./conf/app.docker.toml}:/config.toml:ro + - /etc/localtime:/etc/localtime:ro ports: - "8081:8081" depends_on: diff --git a/internal/containerd/timezone.go b/internal/containerd/timezone.go new file mode 100644 index 00000000..25fba758 --- /dev/null +++ b/internal/containerd/timezone.go @@ -0,0 +1,26 @@ +package containerd + +import ( + "os" + + "github.com/containerd/containerd/v2/pkg/oci" + "github.com/opencontainers/runtime-spec/specs-go" +) + +// TimezoneSpecOpts returns OCI spec options that propagate the host timezone +// into the container via /etc/localtime bind-mount and TZ environment variable. +func TimezoneSpecOpts() []oci.SpecOpts { + var opts []oci.SpecOpts + if _, err := os.Stat("/etc/localtime"); err == nil { + opts = append(opts, oci.WithMounts([]specs.Mount{{ + Destination: "/etc/localtime", + Type: "bind", + Source: "/etc/localtime", + Options: []string{"rbind", "ro"}, + }})) + } + if tz := os.Getenv("TZ"); tz != "" { + opts = append(opts, oci.WithEnv([]string{"TZ=" + tz})) + } + return opts +} diff --git a/internal/containerd/timezone_test.go b/internal/containerd/timezone_test.go new file mode 100644 index 00000000..29f7134f --- /dev/null +++ b/internal/containerd/timezone_test.go @@ -0,0 +1,36 @@ +package containerd + +import ( + "os" + "testing" +) + +func TestTimezoneSpecOpts_WithTZ(t *testing.T) { + t.Setenv("TZ", "Asia/Shanghai") + opts := TimezoneSpecOpts() + if _, err := os.Stat("/etc/localtime"); err == nil { + if len(opts) < 1 { + t.Fatal("expected at least mount opt when /etc/localtime exists") + } + } + found := false + for range opts { + found = true + } + if !found { + t.Fatal("expected at least one spec opt when TZ is set") + } +} + +func TestTimezoneSpecOpts_WithoutTZ(t *testing.T) { + t.Setenv("TZ", "") + opts := TimezoneSpecOpts() + for _, opt := range opts { + if opt == nil { + t.Fatal("unexpected nil spec opt") + } + } + if _, err := os.Stat("/etc/localtime"); err != nil && len(opts) != 0 { + t.Fatalf("expected no opts when /etc/localtime absent and TZ empty, got %d", len(opts)) + } +} diff --git a/internal/handlers/containerd.go b/internal/handlers/containerd.go index 282087ab..b21c58df 100644 --- a/internal/handlers/containerd.go +++ b/internal/handlers/containerd.go @@ -210,6 +210,7 @@ func (h *ContainerdHandler) CreateContainer(c echo.Context) error { }), oci.WithProcessArgs("/bin/sh", "-lc", fmt.Sprintf("bootstrap(){ [ -e /app/mcp ] || { mkdir -p /app; [ -f /opt/mcp ] && cp -a /opt/mcp /app/mcp 2>/dev/null || true; }; if [ -d /opt/mcp-template ]; then mkdir -p %q; for f in /opt/mcp-template/*; do name=$(basename \"$f\"); [ -e %q/\"$name\" ] || cp -a \"$f\" %q/\"$name\" 2>/dev/null || true; done; fi; }; bootstrap; exec /app/mcp", dataMount, dataMount, dataMount)), } + specOpts = append(specOpts, ctr.TimezoneSpecOpts()...) _, err = h.service.CreateContainer(ctx, ctr.CreateContainerRequest{ ID: containerID, @@ -878,6 +879,7 @@ func (h *ContainerdHandler) SetupBotContainer(ctx context.Context, botID string) }), oci.WithProcessArgs("/bin/sh", "-lc", fmt.Sprintf("bootstrap(){ [ -e /app/mcp ] || { mkdir -p /app; [ -f /opt/mcp ] && cp -a /opt/mcp /app/mcp 2>/dev/null || true; }; if [ -d /opt/mcp-template ]; then mkdir -p %q; for f in /opt/mcp-template/*; do name=$(basename \"$f\"); [ -e %q/\"$name\" ] || cp -a \"$f\" %q/\"$name\" 2>/dev/null || true; done; fi; }; bootstrap; exec /app/mcp", dataMount, dataMount, dataMount)), } + specOpts = append(specOpts, ctr.TimezoneSpecOpts()...) _, err = h.service.CreateContainer(ctx, ctr.CreateContainerRequest{ ID: containerID, diff --git a/internal/mcp/manager.go b/internal/mcp/manager.go index 8b610f15..bfdf8b0b 100644 --- a/internal/mcp/manager.go +++ b/internal/mcp/manager.go @@ -134,6 +134,7 @@ func (m *Manager) EnsureBot(ctx context.Context, botID string) error { }, }), } + specOpts = append(specOpts, ctr.TimezoneSpecOpts()...) _, err = m.service.CreateContainer(ctx, ctr.CreateContainerRequest{ ID: m.containerID(botID), diff --git a/internal/mcp/versioning.go b/internal/mcp/versioning.go index c73e8041..cadb7be5 100644 --- a/internal/mcp/versioning.go +++ b/internal/mcp/versioning.go @@ -177,6 +177,7 @@ func (m *Manager) CreateVersion(ctx context.Context, botID string) (*VersionInfo }, }), } + specOpts = append(specOpts, ctr.TimezoneSpecOpts()...) _, err = m.service.CreateContainerFromSnapshot(ctx, ctr.CreateContainerRequest{ ID: containerID, @@ -318,6 +319,7 @@ func (m *Manager) RollbackVersion(ctx context.Context, botID string, version int }, }), } + specOpts = append(specOpts, ctr.TimezoneSpecOpts()...) _, err = m.service.CreateContainerFromSnapshot(ctx, ctr.CreateContainerRequest{ ID: containerID,