Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .claude/rules/pulumi-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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<HetznerConfig>("hetzner"); // optional provider-specific config (validated at runtime)
cfg.requireSecret("tailscaleAuthKey"); // secret string
cfg.requireObject<EgressRule[]>("egressPolicy"); // structured object
Expand All @@ -66,7 +68,7 @@ cfg.requireObject<EgressRule[]>("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?
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ Configuration is managed via `pulumi config` / `Pulumi.<stack>.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-<profile>` | secret | no | Auth token override (auto-generated if omitted) |
| `gatewayEnv-<profile>-<KEY>` | secret | no | Individual secret env var for init + runtime (e.g. `gatewayEnv-main-OPENROUTER_API_KEY`) |
Expand Down
1 change: 1 addition & 0 deletions Pulumi.dev.yaml.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
config:
openclaw-deploy:autoUpdate: "true"
openclaw-deploy:timezone: America/Los_Angeles
openclaw-deploy:hetzner:
backups: true
openclaw-deploy:dockerhubPush: "true"
Expand Down
20 changes: 19 additions & 1 deletion components/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as command from "@pulumi/command";
export interface HostBootstrapArgs {
connection: pulumi.Input<command.types.input.remote.ConnectionArgs>;
autoUpdate?: boolean;
timezone?: string;
}

export class HostBootstrap extends pulumi.ComponentResource {
Expand Down Expand Up @@ -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],
},
Comment thread
schmitthub marked this conversation as resolved.
{ 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).
Expand All @@ -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);
Expand Down
50 changes: 11 additions & 39 deletions components/gateway-image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,51 +307,23 @@ 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.
//
Comment thread
schmitthub marked this conversation as resolved.
// forceRemove ensures destroy removes the image even when running
// containers reference it (findImage() short-circuit workaround).
const pulledImage = new docker.RemoteImage(
`${name}-pull`,
{
name: pullTag,
platform: args.platform,
pullTriggers: [pullDigest, imageDigestTrigger],
pullTriggers: [imageDigestTrigger],
forceRemove: true,
},
{
Expand All @@ -363,7 +335,7 @@ export class GatewayImage extends pulumi.ComponentResource {

return {
imageName: pulledImage.imageId,
imageDigest: pullDigest,
imageDigest: imageDigestTrigger,
};
}

Expand Down
1 change: 1 addition & 0 deletions config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions tests/components.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Loading