diff --git a/.claude/rules/pulumi-config.md b/.claude/rules/pulumi-config.md index b2821d8..304e209 100644 --- a/.claude/rules/pulumi-config.md +++ b/.claude/rules/pulumi-config.md @@ -30,6 +30,7 @@ config: # openclaw-deploy:multiPlatform: true # Optional: build amd64 + arm64 (requires dockerhubPush + platform) # openclaw-deploy:platform: linux/amd64 # Required when multiPlatform is true (e.g. linux/amd64, linux/arm64) # openclaw-deploy:autoUpdate: true # Optional: automatic security updates via unattended-upgrades + # openclaw-deploy:timezone: America/Los_Angeles # Optional: IANA timezone for VPS # openclaw-deploy:hetzner: # Optional: Hetzner-specific options # backups: true # automatic daily backups (+20% server cost) openclaw-deploy:gatewayToken-dev: @@ -45,6 +46,7 @@ cfg.getBoolean("dockerhubPush"); // optional boolean (default: false) cfg.getBoolean("multiPlatform"); // optional boolean (default: false, only with dockerhubPush) cfg.get("platform"); // optional string (e.g. "linux/amd64", required when multiPlatform is true) cfg.getBoolean("autoUpdate"); // optional boolean (default: false) +cfg.get("timezone"); // optional string (e.g. "America/Los_Angeles") cfg.getObject("hetzner"); // optional provider-specific config (validated at runtime) cfg.requireSecret("tailscaleAuthKey"); // secret string cfg.requireObject("egressPolicy"); // structured object @@ -66,7 +68,7 @@ cfg.requireObject("egressPolicy"); // structured object ## Component Argument Patterns Components accept typed args interfaces (5 per gateway, plus shared infra): - `ServerArgs`: provider, serverType, region?, sshKeyId?, image?, hetzner?, compartmentId?, subnetId?, ocpus?, memoryInGbs? -- `HostBootstrapArgs`: connection, autoUpdate? +- `HostBootstrapArgs`: connection, autoUpdate?, timezone? - `EnvoyEgressArgs`: connection, egressPolicy - `GatewayImageArgs`: connection, dockerHost, profile, version, installBrowser?, imageSteps?, dockerhubPush?, multiPlatform?, platform? - `TailscaleSidecarArgs`: connection, dockerHost, profile, port, tailscaleAuthKey, tcpPortMappings? diff --git a/AGENTS.md b/AGENTS.md index 26116b2..75a58a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -138,6 +138,7 @@ Configuration is managed via `pulumi config` / `Pulumi..yaml`: | `multiPlatform` | boolean | no | Build for amd64 + arm64 when `dockerhubPush` is true (default: false) | | `platform` | string | no | Docker platform of the VPS, e.g. `linux/amd64`. Required when `multiPlatform` is true | | `autoUpdate` | boolean | no | Automatic security updates via `unattended-upgrades` (default: false) | +| `timezone` | string | no | IANA timezone for VPS (e.g. `America/Los_Angeles`). Default: OS default. | | `hetzner` | `HetznerConfig` | no | Hetzner-specific options (`{ backups?: boolean }`) | | `gatewayToken-` | secret | no | Auth token override (auto-generated if omitted) | | `gatewayEnv--` | secret | no | Individual secret env var for init + runtime (e.g. `gatewayEnv-main-OPENROUTER_API_KEY`) | diff --git a/Pulumi.dev.yaml.example b/Pulumi.dev.yaml.example index 67e9040..96af131 100644 --- a/Pulumi.dev.yaml.example +++ b/Pulumi.dev.yaml.example @@ -1,5 +1,6 @@ config: openclaw-deploy:autoUpdate: "true" + openclaw-deploy:timezone: America/Los_Angeles openclaw-deploy:hetzner: backups: true openclaw-deploy:dockerhubPush: "true" diff --git a/components/bootstrap.ts b/components/bootstrap.ts index 153a87a..a2a2927 100644 --- a/components/bootstrap.ts +++ b/components/bootstrap.ts @@ -4,6 +4,7 @@ import * as command from "@pulumi/command"; export interface HostBootstrapArgs { connection: pulumi.Input; autoUpdate?: boolean; + timezone?: string; } export class HostBootstrap extends pulumi.ComponentResource { @@ -57,7 +58,23 @@ export class HostBootstrap extends pulumi.ComponentResource { ); } - // Step 1b: Configure SSH AcceptEnv so Pulumi can pass env vars via setenv. + // Step 1b: Set VPS timezone (opt-in). Uses timedatectl which works on all + // supported providers (Hetzner/Ubuntu, DigitalOcean/Ubuntu, Oracle/Oracle Linux). + let setTimezone: command.remote.Command | undefined; + if (args.timezone) { + const TIMEZONE_CMD = `timedatectl set-timezone ${args.timezone}`; + setTimezone = new command.remote.Command( + `${name}-timezone`, + { + connection: args.connection, + create: TIMEZONE_CMD, + triggers: [TIMEZONE_CMD], + }, + { parent: this, dependsOn: [installDocker] }, + ); + } + + // Step 1c: Configure SSH AcceptEnv so Pulumi can pass env vars via setenv. // Separate resource so it runs even when installDocker is already in state. // Uses sshd_config.d/ for global scope (avoids Match block scoping issues). // Service name varies: ssh (Ubuntu/Debian) vs sshd (RHEL/Fedora/Hetzner). @@ -81,6 +98,7 @@ export class HostBootstrap extends pulumi.ComponentResource { configureAcceptEnv.stdout, ]; if (enableAutoUpdates) bootstrapOutputs.push(enableAutoUpdates.stdout); + if (setTimezone) bootstrapOutputs.push(setTimezone.stdout); this.dockerReady = pulumi.all(bootstrapOutputs).apply(() => "ready"); const conn = pulumi.output(args.connection); diff --git a/components/gateway-image.ts b/components/gateway-image.ts index 89689a8..70451c7 100644 --- a/components/gateway-image.ts +++ b/components/gateway-image.ts @@ -307,43 +307,15 @@ export class GatewayImage extends pulumi.ComponentResource { ? remoteTag : `docker.io/${remoteTag}`; - // Query the registry for the current manifest digests. This is the source - // of truth — it reflects what was actually pushed, not what we built locally. - // Uses getRegistryImageManifests (not getRegistryImage) because we push - // multi-arch manifest lists. Auth is passed directly — no provider needed - // for registry API calls, which are independent of any Docker daemon. - const registryManifests = docker.getRegistryImageManifestsOutput( - { - name: remoteTag, - authConfig: { - address: "registry-1.docker.io", - username, - password, - }, - }, - { parent: this }, - ); - - // Extract the digest for the target platform. For single-platform builds, - // there's only one manifest. For multi-platform, match the VPS architecture. - const targetArch = args.platform?.split("/")[1] ?? "amd64"; - const pullDigest = registryManifests.manifests.apply((manifests) => { - const match = manifests.find( - (m) => m.architecture === targetArch && m.os === "linux", - ); - if (!match) { - throw new Error( - `No manifest found for linux/${targetArch} in ${remoteTag}`, - ); - } - return match.sha256Digest; - }); - - // Two triggers ensure the pull always fires: - // 1. pullDigest (registry manifest) — catches out-of-band pushes and - // state desync (registry is the source of truth) - // 2. imageDigestTrigger (build output) — catches same-deploy rebuilds - // where the image hasn't been pushed to the registry yet at plan time + // Pull trigger uses the build output digest only. A previous design also + // queried the registry via getRegistryImageManifestsOutput as a second + // trigger, but that data source runs at plan time (before the build) so it + // always captures the PRE-build digest. After a build pushes a new image, + // the registry content changes but the state already recorded the stale + // pre-build value — causing a spurious replacement on the next deploy. + // The build digest is sufficient: it changes whenever build inputs change, + // which is the only time a new image is pushed. + // // forceRemove ensures destroy removes the image even when running // containers reference it (findImage() short-circuit workaround). const pulledImage = new docker.RemoteImage( @@ -351,7 +323,7 @@ export class GatewayImage extends pulumi.ComponentResource { { name: pullTag, platform: args.platform, - pullTriggers: [pullDigest, imageDigestTrigger], + pullTriggers: [imageDigestTrigger], forceRemove: true, }, { @@ -363,7 +335,7 @@ export class GatewayImage extends pulumi.ComponentResource { return { imageName: pulledImage.imageId, - imageDigest: pullDigest, + imageDigest: imageDigestTrigger, }; } diff --git a/config/types.ts b/config/types.ts index 61c7b53..73e0802 100644 --- a/config/types.ts +++ b/config/types.ts @@ -121,6 +121,7 @@ export interface StackConfig { // Host management autoUpdate?: boolean; // automatic security updates via unattended-upgrades (default: false) + timezone?: string; // IANA timezone for VPS (e.g. "America/Los_Angeles", "UTC"). Default: OS default. // Build dockerhubPush?: boolean; // build locally + push to Docker Hub (default: false) diff --git a/index.ts b/index.ts index 78899f4..74f9433 100644 --- a/index.ts +++ b/index.ts @@ -104,6 +104,7 @@ const server = new Server("server", { const bootstrap = new HostBootstrap("bootstrap", { connection: server.connection, autoUpdate: cfg.getBoolean("autoUpdate") ?? false, + timezone: cfg.get("timezone"), }); // 3. Render egress config + generate certificates diff --git a/tests/components.test.ts b/tests/components.test.ts index 7ae705d..795c58f 100644 --- a/tests/components.test.ts +++ b/tests/components.test.ts @@ -480,6 +480,22 @@ describe("HostBootstrap component", () => { const dockerHost = await promiseOf(bootstrap.dockerHost); expect(dockerHost).toMatch(/^ssh:\/\/root@/); }); + + it("creates successfully with timezone set", async () => { + const { HostBootstrap } = await import("../components/bootstrap"); + const bootstrap = new HostBootstrap("test-bootstrap-timezone", { + connection: { host: "1.2.3.4", user: "root" }, + timezone: "America/Los_Angeles", + }); + + expect(bootstrap).toBeDefined(); + + const dockerReady = await promiseOf(bootstrap.dockerReady); + expect(dockerReady).toBe("ready"); + + const dockerHost = await promiseOf(bootstrap.dockerHost); + expect(dockerHost).toMatch(/^ssh:\/\/root@/); + }); }); describe("EnvoyEgress component MITM validation", () => {