Infra program
The Pulumi-managed application stack is provisioned by app/packages/infra
(@hyveon/infra) — a Pulumi Automation API program: ordinary TypeScript
functions that declare @pulumi/aws resources, driven entirely from inside
the packaged Electron app by PulumiService. There is no separate
infrastructure-as-code file tree on disk to edit or run for this stack — the
program's source is this package. Two exceptions provision AWS resources
outside it: the one-time IAM bootstrap CloudFormation template run before the
app ever calls Pulumi (see setup), and the runs
table
below, created via the AWS SDK instead of Pulumi for a correctness reason.
How it's invoked — no host-installed pulumi binary
PulumiService (app/packages/desktop-main/src/services/PulumiService.ts)
never shells out to a pulumi command it hopes is on PATH. Instead:
- The program is inline, not a file on disk. Every stack operation goes
through
PulumiWorkspaceService.getOrCreateStack(), which builds the workspace itself via the lower-levelLocalWorkspace.create(opts)(not the now-unusedcreateOrSelectStack()convenience wrapper) and then callsStack.createOrSelect(PULUMI_STACK_NAME, ws)— both from@pulumi/pulumi/automation. Forpreviewandapply,programiscreateInfraProgram(deploymentConfig, { lambdaBundlesDir })— a function from this package, evaluated in-process.destroydeliberately passes a no-op program instead, so tearing down a stack never requires reading (or being blocked by) a broken configuration object. - The Pulumi CLI engine itself is still required — the app provisions it,
not the operator. The Automation API is a Node wrapper around a real
pulumibinary;PulumiEngineServicedownloads and verifies the exact pinned version (PULUMI_ENGINE_VERSIONin@hyveon/shared, currently matching this package's own@pulumi/pulumidependency) into an app-owned directory under Electron'suserData— never~/.pulumi, and it never probesPATH. The resolvedPulumiCommandis passed explicitly intoLocalWorkspaceOptions.pulumiCommand. An operator following the setup wizard never installs anything by hand. @pulumi/pulumiand@pulumi/awsare pinned to exact versions (no caret) in bothapp/packages/infra/package.jsonandapp/packages/desktop-main/package.json, kept identical on purpose —PulumiServicereads@pulumi/aws's installed version to decide which provider plugin to install for a stack.
State backend — self-managed S3, no DynamoDB lock table
Unlike an S3 backend that pairs a bucket with a companion DynamoDB lock table, the Pulumi stack uses Pulumi's own DIY S3 backend and needs no separate lock table:
LocalWorkspaceOptions.envVars.PULUMI_BACKEND_URLis set tos3://<stateBucket>?region=<region>— the same state bucket the first-run wizard's bootstrap step creates (versioned, AES-256 encrypted, no public access).secretsProvider: 'passphrase'— there is no Pulumi Cloud account and no access token anywhere in this app.- The passphrase itself is derived, not stored. On every
PulumiWorkspaceService.getOrCreateStackcall, the seam resolves the AWS account ID the active credential source authenticates against (sts:GetCallerIdentity,resolveAwsAccountId) and runsHMAC-SHA256(PULUMI_PASSPHRASE_DERIVATION_SALT, accountId + PULUMI_STACK_NAME)(deriveStackPassphrase). The result is never written toElectronStoreServiceor anywhere else on disk — any machine authenticated against the same AWS account reproduces the identical passphrase. This fixes a real gap: a second or replacement machine pointed at the same S3 state bucket previously had no way to decrypt the existing stack's state, since the old passphrase was generated once and stored encrypted (viaSafeStorageService, backed by the OS keychain) in that one install's local config only.PULUMI_PASSPHRASE_DERIVATION_SALTis frozen once shipped — changing it would re-key every already-migrated install. - This passphrase is not a confidentiality boundary. The infrastructure
program never calls
pulumi.secret(...)on any config or output (program.ts), so nothing sensitive is actually gated behind it — it exists only because the Automation API requires a secrets provider to be configured at all. Deriving it from an account ID and a fixed stack name (both already visible to anyone with read access to the AWS account) is fine precisely because of that. - Installs that still hold a legacy stored passphrase (
pulumi.passphraseinElectronStoreService, now@deprecatedand read-only) migrate automatically and silently on first run under this code: the stack is re-encrypted from the legacy passphrase to the newly derived one via thepulumiCLI, and the legacy entry is deleted only once that succeeds. SeePulumiWorkspaceService.migrateLegacyPassphrasefor the mechanism. A stack created before this change ships is still encrypted under its legacy passphrase until some install actually runs this migration — a brand-new second machine cannot derive its way into decrypting it until then, since the derived value only matches what the stack is encrypted with after that one-time re-encryption has happened somewhere. In practice this is a non-issue: the first launch of any install already running this code (including the original machine) performs the migration automatically, so it is very unlikely an operator would ever reach for a second machine before that has already occurred once.pulumi.stackInitializedis separate, purely-local bookkeeping ("has this install ever created/selected the stack") — it plays no role in passphrase resolution. - Locking is a lock object written into the state bucket itself, not a
DynamoDB table — this is how Pulumi's CLI implements its self-managed S3
backend. A lock the app can prove is its own dead orphan (same process
identity, no longer alive) is reclaimed automatically; any other stale lock
is cleared only through the operator-confirmed
PulumiService.clearStaleLock(). - This is a distinct concept from the app's own apply lock:
RunServiceadditionally guards concurrent apply and destroy submissions (plan takes only the in-process workspace guard) with an in-memory lock mirrored to a DynamoDB item in the runs table. Don't confuse the two — one guards the Pulumi backend itself, the other guards the app's own IPC-level submission queue.
Configuration input
The program takes a single DeploymentConfig object (@hyveon/shared) as
its only input — there is no separate variables file of any kind.
DeploymentConfig.gameServers: Record<string, GameServerConfig> is
the single source of truth for per-game resources; it's persisted as the
JSON object deployment-config.json in the operator's S3 configuration
bucket. PulumiService fetches that object, JSON.parses it, and passes it
into createInfraProgram(). See
Management app — DeploymentConfigModule / DeploymentConfigService
for how the desktop app reads and writes the same object.
There is no single for_each-style loop over this map. defineAll()
(program.ts) calls each resource-defining function once, in a fixed
dependency order, and each function loops internally over
config.gameServers to produce its own per-game resources. Adding or
removing a game means adding or removing exactly one map entry — every
per-game resource across every file below still fans out from that one
object.
Each game's environment values (container environment variables declared
in the deployment configuration) are echoed verbatim into the stack outputs
Pulumi's engine prints (program.ts's appliedGameServers) — PulumiService
redacts every sufficiently long (4+ characters) value, drawn from the
current deployment configuration, out of preview/apply's streamed
stdout/stderr before it reaches the run-log viewer or the persisted
pulumi.log. Shorter values pass through unredacted. See
Infrastructure — Run plan.
Files
Every source file under app/packages/infra/src/ and what it declares.
"Fixed" means the resource is always created once; "per-game"/"conditional"
means the count depends on config.gameServers.
| File | Purpose | Resources |
|---|---|---|
network.ts | VPC and public networking. | aws.ec2.Vpc (1), InternetGateway (1), Subnet (2, fixed — not config-driven), RouteTable (1, with an inline default route), RouteTableAssociation (2). |
securityGroups.ts | The security groups guarding game tasks, the file manager, EFS, the EFS-seeder Lambdas, and the health-check Lambda. | aws.ec2.SecurityGroup — 3 fixed (game servers, file manager, EFS) + 1 conditional (EFS-seeder, only when at least one game declares file_seeds) + 1 conditional (health-check, only when at least one game declares healthCheck). Ingress/egress on the three fixed groups are inline arrays, EXCEPT the EFS-seeder and health-check groups' egress: each is one or more conditional standalone aws.ec2.SecurityGroupRules instead (EFS-seeder: one rule, port 2049/tcp to the EFS security group; health-check: one rule per distinct declared health-check port, to the game-servers security group) — neither seeder nor health-check group carries any inline egress at all, so the standalone rules can't conflict with an inline one on the same group (see the file's own doc, "efsSeederSg's egress — standalone rule, not inline"). The file-manager group's ingress is an explicit empty array: the desktop app authorizes and revokes <operator-ip>/32:8080 on it at runtime, so nothing is open to 0.0.0.0/0. The health-check group's matching ingress (into gameServers) is a second in-line entry per declared port on that group's own ingress array, the same shape as the EFS-seeder group's ingress into efs. gameServers's ingress array also includes one entry per game port (an https: true game's extra ports included, its webPort never) declared visibility: 'internal', sourced from the VPC's own CIDR block (aws.ec2.getVpcOutput({ id: vpcId }), a plain data lookup — no resource declared) rather than 0.0.0.0/0 — alongside the public/HTTPS/health-check-sourced entries already described above. An icmp port entry becomes an ingress rule with protocol: 'icmp', fromPort set to the declared ICMP type, and toPort: -1 (all codes) — see "ICMP port declarations" below. |
efs.ts | The shared encrypted EFS filesystem and its access points. | aws.efs.FileSystem (1), MountTarget (one per public subnet), AccessPoint (one per game/volume pair, plus one per HTTPS game for Caddy's certificate storage). |
ecs.ts | The ECS cluster and per-game task definitions. | aws.ecs.Cluster (1, with the containerInsights setting enabled so Container Insights publishes the per-task-family NetworkRxBytes the watchdog reads; roughly one cent per running game-hour), aws.cloudwatch.LogGroup (two per game: /ecs/{game}-server, and /ecs/filebrowser-{game} for the on-demand FileBrowser helper task — pre-created because the task-execution role cannot logs:CreateLogGroup; any already-existing group of that name — hand-created, or auto-created by a helper launched before this change — must be deleted or imported before apply), aws.ecs.TaskDefinition (one per game, family {game}-server). No aws.ecs.Service is ever declared — upholding the no-persistent-Service invariant. The per-game log groups and task definition all carry a Game=<game> tag (see "Cost allocation tags" below); the cluster does not. An https: true game's portMappings start with its webPort as a tcp mapping, followed by its extra ports; the Caddy sidecar's command is caddy reverse-proxy --from {game}.{zone} --to localhost:<webPort> (requireWebPort throws if an https game reaches defineEcs without a webPort, i.e. skipped the normalising read path). A game's icmp port entries are excluded from the container's portMappings — ECS rejects any protocol other than tcp/udp there, so an icmp entry produces only the security-group ingress rule above, never a port mapping. Env-token interpolation in a game's environment values (below) also lives here, alongside envTokenWrapper.ts. For a game that sets pinnedHostname, defineEcs also adds a hostname-shim init container and an ephemeral {game}-hostname-shim volume to that game's task definition (see "Pinned hostname" below); it declares no extra resource for it. |
hostnameShim.ts | Builds the pieces of the pinned-hostname feature that ecs.ts wires into a task definition: the shim source, the hostname-shim init-container definition, the ephemeral volume name, and the NODE_OPTIONS merge. | No Pulumi resources — pure helpers consumed by ecs.ts's defineEcs, like envTokenWrapper.ts. |
envTokenWrapper.ts | Generates the inline /bin/sh -c boot wrapper ecs.ts injects when a game's env values need the public-IPv4 token resolved at container start. | No Pulumi resources — a pure string-building helper consumed by ecs.ts's defineEcs. |
iam.ts | Every IAM role and inline policy, split into defineIamRoles/defineIamPolicies because policies need a Lambda ARN that doesn't exist until after lambdas.ts runs. | aws.iam.Role — 6 fixed (task execution, watchdog, followup, interactions, dns-updater, FileBrowser auto-stop scheduler) + 1 per game with file_seeds + 1 conditional, single shared role (health-check — not per-game, unlike the EFS-seeder role, since one function serves every opted-in game). RolePolicyAttachment (1, the managed ECS task-execution policy). RolePolicy — 5 fixed + 1 per seeder game + 1 conditional (health-check's own policy). The scheduler role trusts scheduler.amazonaws.com and its policy grants only ecs:StopTask, scoped to the deployed cluster's tasks — used by FileManagerService's per-launch auto-stop schedule, not by any Lambda. The watchdog's own policy gains a conditional lambda:InvokeFunction statement, scoped to the health-check function's ARN, only when that function exists. |
lambdas.ts | The six Lambda functions (two conditional), their log groups, the interactions Function URL, and the two EventBridge rule/target pairs. | aws.lambda.Function — 4 fixed + 1 per seeder game ({projectName}-efs-seeder-{game}) + 1 conditional, single shared function ({projectName}-health-check). aws.cloudwatch.LogGroup — 4 fixed + 1 per seeder game + 1 conditional. aws.lambda.FunctionUrl (1). aws.lambda.Permission (4 — none for health-check, which is invoked only via the IAM identity-policy grant above, never a resource-based permission). aws.cloudwatch.EventRule (2: watchdog schedule, ECS task-state-change). EventTarget (2). The per-seeder-game function and log group carry a Game=<game> tag; the 4 fixed Lambdas and the health-check Lambda (shared across every opted-in game) do not. firstPortByGame(), which builds the GAME_PORTS env var shared by the followup and dns-updater Lambdas (the port a Discord /start reply tells the player to connect to), excludes icmp ports before picking a game's first port — an ICMP type isn't something a player connects to. https: true games map to the string 'https' (HTTPS_CONNECT_PORT) instead of a number, so GAME_PORTS values are number | 'https'; the player connects through the public 443 URL, not webPort or an extra port. |
dynamodb.ts | The two DynamoDB tables this program manages. | aws.dynamodb.Table — 2 fixed: Discord state (TTL on expiresAt), audit log. Both PAY_PER_REQUEST. The run-history table is bootstrap-managed, not declared here — see "The runs table invariant" below. |
secrets.ts | The two Discord Secrets Manager secrets plus the FileBrowser helper's shared credential-hash secret, and all three's create-only placeholder versions. | aws.secretsmanager.Secret (3, recoveryWindowInDays: 0). SecretVersion (3, seeded with a placeholder string and ignoreChanges: ['secretString'] so the app can edit them afterwards without a redeploy overwriting the value). The FileBrowser secret is ONE shared secret across every game (not per-game) — FileManagerService overwrites it with a fresh bcrypt hash on every launch, purely as an audit record; the container itself gets the hash directly via command-line flags, never by reading this secret back. |
route53.ts | Hosted-zone lookup only. | Zero Pulumi resources — one data-source call, aws.route53.getZoneOutput(). See the DNS invariant below. |
escapes.ts | The imperative "escape hatches" that don't fit a declarative resource model: seeding a DynamoDB config row and invoking the EFS-seeder Lambdas. | aws.dynamodb.TableItem (0–2, conditional on Discord config being set). aws.lambda.Invocation — one per game with file_seeds, re-triggered only when that game's seed content hash changes. |
discordDomain.ts | The CloudFront-fronted discord.{hostedZoneName} custom domain in front of the interactions Lambda's Function URL (Function URLs can't be Route 53 ALIAS targets directly). | aws.acm.Certificate (1, us-east-1), aws.route53.Record (1, the ACM DNS-validation record), aws.acm.CertificateValidation (1, us-east-1), aws.cloudfront.Distribution (1), aws.route53.Record (2 more — A and AAAA ALIASes to the distribution). |
program.ts | The package's entry point: constructs both AWS providers, calls every defineX() in dependency order, and builds the stack outputs object. | aws.Provider (2 — the default region, plus a fixed us-east-1 alias for the Discord domain's ACM certificate, which CloudFront requires). |
index.ts | Barrel re-export of most defineX() functions, helpers, and types — but not all: defineAll (only called from program.ts), stripTrailingDots, dedupedInternalGamePorts, and gamesWithHealthChecks are internal to their own files and not re-exported here. | none |
testing/fixtures.ts, testing/pulumiMocks.ts | Test-only: shared game-config fixtures and a pulumi.runtime.setMocks() harness. | none |
EFS access point stack outputs
efs.ts creates one access point per (game, volume) pair, keyed {game}-{volumeName} in efs.gameAccessPoints and rooted at /{game}/{volumeName}. program.ts's buildStackOutputs exports them through two outputs, both mirrored on StackOutputs in @hyveon/shared and projected by PulumiService.projectStackOutputs (each with an {} fallback for a stack applied before the output existed):
| Output | Shape | Covers | Why it exists |
|---|---|---|---|
efsAccessPoints | Record<game, accessPointId> | The game's first volume only (volumes[0]). | The original output. Kept unchanged for existing readers and as the single-volume fallback below. |
efsVolumeAccessPoints | Record<game, Record<volumeName, accessPointId>> | Every volume of every game. | The file-manager helper mounts every applied volume, so it needs every access point ID, not just the first. |
efsVolumeAccessPoints is built from the access points that already exist: it adds no resources and no IAM change. buildStackOutputs throws if a game has no volumes, or if a configured volume has no matching efs.gameAccessPoints entry (the two have drifted apart).
With only efsAccessPoints[game] the helper can mount just the first volume, and an upload aimed at a second volume lands in the first (#672). The helper's mount layout (buildHelperVolumeLayout, desktop-main/src/services/volumeFiles/helperVolumeLayout.ts) therefore reads efsVolumeAccessPoints[game].
Upgrading: a stack applied by an older build has no efsVolumeAccessPoints output, so PulumiService projects {} and the helper falls back to mounting only the first volume (via efsAccessPoints) for browsing, and folder upload is refused for every volume because that access point's volume identity can't be confirmed. Run one plan and apply from the Infrastructure page after upgrading to pick the output up. The new output by itself adds no resource changes, so the plan shows only an output diff unless the configuration has other pending changes.
Cost allocation tags
Every Pulumi-managed resource carries Project=hyveon (applied once via
defaultTags on both aws.Providers in program.ts). In addition, the
resources whose cost AWS meters independently per game — per-game ECS task
definitions and their CloudWatch log groups (ecs.ts), and the per-game
EFS-seeder Lambda and its log group (lambdas.ts) — carry a Game=<game>
tag, where <game> is the game's key in DeploymentConfig.gameServers.
Resources shared across every game (the ECS cluster, security groups,
DynamoDB tables, the four fixed project-wide Lambdas, the conditional
health-check Lambda — a single shared function, not one per game — and the
EFS filesystem and its access points) intentionally do not carry a
Game tag — EFS in
particular bills at the filesystem level, so tagging its per-game access
points would not let Cost Explorer split EFS cost by game (access points
aren't separately billed resources).
Dynamically-launched ECS Fargate tasks (via RunTask, never a persistent
aws.ecs.Service — see the no-persistent-Service invariant above) inherit
Game from their task definition via propagateTags: 'TASK_DEFINITION',
set at both RunTask call sites — AwsCloudProvider.startWorkload (desktop
app) and the followup Lambda's runStart (Discord /start command) — this
is what makes the tag reach the resource AWS actually bills Fargate compute
against.
One-time manual step required — Pulumi cannot do this: to see costs
broken down by Game in AWS Cost Explorer, activate Game (and Project,
if not already active) as a cost allocation tag: AWS Billing console →
Cost allocation tags → select the tag → Activate. This is not retroactive
by default (only usage after activation is tagged in cost data) and can take
up to 24 hours to appear in Cost Explorer. A management-account user can
retroactively backfill up to the previous 12 months via the AWS Billing
console or the StartCostAllocationTagBackfill API — this only recovers
cost data for resources that already carried the tag during that period, and
can be requested at most once every 24 hours.
Once activated, pull a per-game breakdown with:
aws ce get-cost-and-usage \
--time-period Start=2026-08-01,End=2026-09-01 \
--granularity MONTHLY \
--metrics UnblendedCost \
--group-by Type=TAG,Key=Game
The DNS invariant, precisely
No per-game DNS record is a Pulumi resource. route53.ts declares zero
resources — only a hosted-zone lookup — and its file doc carries an explicit
invariant comment enforced by a negative test assertion. Per-game hostnames
({game}.{hostedZoneName}) are UPSERTed and DELETEd exclusively by
@hyveon/lambda-update-dns in response to ECS task state changes; adding a
per-game aws.route53.Record anywhere in this program would fight that
Lambda.
The only aws.route53.Record resources in the whole program are the
three static, fixed records in discordDomain.ts for the Discord bot's own
custom subdomain — unrelated to any game, never touched by any Lambda.
Health-check network confinement is port-level, not game-level
All game-server tasks share the one gameServers security group — there
are no per-game security groups. So the health-check Lambda's egress rules
(one per distinct port declared across every opted-in game's healthCheck)
confine it to ports, not games: a game that declares no health
check is still reachable on that port if some other, opted-in game
happens to declare the same one. This is accepted deliberately rather than
overclaiming game-level isolation — the request is still addressed to a
specific task's private address resolved from ECS, so reachability alone
never redirects a check at the wrong task. Splitting gameServers into
per-game security groups would close this gap but touches every ingress
rule and task definition in the program; not done here.
Per-port visibility: public vs. internal ingress
Each entry in a game's ports array (GameServerPort, @hyveon/shared)
carries an optional visibility?: 'public' | 'internal' field. undefined
is treated identically to 'public' — there is no third state — matching
the same undefined ≡ default contract as GameServerConfig.https. Only
the entries returned by ingressPorts() (@hyveon/shared) are affected: securityGroups.ts derives both the
public and internal ingress sets (dedupedDirectGamePorts /
dedupedInternalGamePorts) from it. For a non-HTTPS game that is every
entry in ports. For an https: true game it is the extra ports entries
(UDP voice or game traffic, for example) — each follows visibility like any
other port — but never webPort: that is the Caddy upstream and stays
unreachable except through the sidecar's fixed, public 443/80 ingress, which
is unaffected by visibility. The one exception is a healthCheck targeting
webPort: the health-check Lambda's security-group-sourced ingress rule opens
that port to the Lambda's own security group directly (see "webPort and the
legacy normaliser" below), though still never to the internet.
Behaviour change for existing HTTPS games: before the explicit webPort
field, none of an HTTPS game's ports received ingress. A legacy config
(https: true, no webPort) is normalised at read time — ports[0]
becomes webPort and the remaining entries become extra ports — so any
former ports[1..] entries are now publicly ingressed (or VPC-only if
internal) on the next apply. Review the plan before approving it; the new
ingress rules appear in the preview.
'public'or omitted — ingress sourced from0.0.0.0/0, exactly as before this field existed.'internal'— ingress sourced from the VPC's own CIDR block, resolved once viaaws.ec2.getVpcOutput({ id: vpcId })and reused for every internal-visibility port. This makes the port reachable from anything inside the VPC — not scoped to a specific caller, Lambda, or task. It is a coarser restriction than "only the health-check Lambda can reach this," the same caveat the previous section makes about health-check egress rules: marking a health-check port'internal'narrows its exposure from the whole internet down to the whole VPC, not down to the health-check Lambda specifically — the health-check Lambda's own SG-sourced ingress rule (see above) is what actually scopes reachability to that one caller, and it exists independently ofvisibility.
A port can never appear in both the public and internal ingress sets on the
validated write path: checkPortCollisions (@hyveon/shared's
gameServerValidator.ts) already rejects two ports sharing a (port, protocol) pair, so a given port/protocol key carries exactly one
visibility value there. A hand-edited deployment-config.json is not
re-validated on read, so that guarantee only holds for configs written through
the validated path (the wizard / GamesWriteService), not for a file edited
by hand outside it.
ICMP port declarations — pings, not connections
Some games' community server browsers (Palworld's, for one) send an ICMP echo request to a server before offering to join it — a plain TCP/UDP port doesn't answer that. A ports entry with protocol: 'icmp' models this: container is read as the ICMP type rather than a port number (8 = echo request/ping), mirroring how EC2 security-group rules themselves encode an ICMP type in FromPort and use ToPort: -1 for "all codes." ingressRule() (securityGroups.ts) branches on isIcmpProtocol(protocol) (case-insensitively, matching how the validator accepts a hand-edited protocol: 'ICMP') to build exactly that shape: { protocol: 'icmp', fromPort: <type>, toPort: -1 }, with description ICMP type <n> ( (internal) appended for a visibility: 'internal' entry, same suffix convention as the tcp/udp rules above). An icmp entry follows the same public/internal, !config.https filtering as every other port — a https: true game still can't declare one, per the existing HTTPS tcp/udp-only rule.
icmp entries never reach two places a normal port does:
- ECS
portMappings(ecs.ts) — ECS rejects any protocol other thantcp/udpthere, sodefineEcsfiltersicmpports out before mappingconfig.portstoportMappings. The security-group ingress rule is the only resource anicmpentry produces. - The Discord connect-port hint (
lambdas.ts'sfirstPortByGame()) — it filters outicmpports before picking the game's first port for theGAME_PORTSmap shared by the followup and dns-updater Lambdas, so a game that declaresicmpbefore its real port still advertises the real port in the/startreply, and a game with only anicmpport is omitted fromGAME_PORTSentirely.
Unlike tcp/udp ports, the same (container, protocol) icmp pair is allowed to repeat across different games — checkPortCollisions exempts icmp from its cross-game collision rule — as long as every declaration shares the same effective visibility; dedupedDirectGamePorts / dedupedInternalGamePorts (both securityGroups.ts) key their dedup Map on container-protocol, so two games both declaring { container: 8, protocol: 'icmp' } still produce exactly one ingress rule. When an operator does need to move two such games to different visibility, the validator rejects a one-at-a-time edit (each single edit conflicts with the not-yet-updated sibling) — the error message names the workaround: remove the icmp entry from one game, change the other's visibility, then re-add it matching.
Operator migration note: if a game already has an unmanaged, manually-added ICMP security-group rule (added outside Pulumi before this feature existed), the next pulumi up removes it unless that game's configuration also declares the matching { container: <type>, protocol: 'icmp' } port entry — Pulumi owns the security group's ingress list wholly, so an entry it doesn't know about doesn't survive an apply.
webPort and the legacy normaliser
GameServerConfig.webPort (required when https: true, forbidden
otherwise; integer 1–65535 and not 80/443) is the port the game's own web
server listens on and the only upstream Caddy proxies to. A tcp entry in
ports that duplicates webPort is rejected by the validator and filtered
out of ingress by ingressPorts(). A healthCheck.port may be webPort or
a declared tcp extra; the health-check Lambda's SG-sourced ingress rule is
declared on whichever port the check targets, so it works for webPort even
though webPort has no public rule.
normalizeGameServer (shared/src/gameServerPorts.ts) is deprecated
read-time compatibility for configs written before webPort existed:
for an https: true game with no webPort whose ports[0] is tcp, it
sets webPort = ports[0].container and ports = ports.slice(1). It is
idempotent and does not rewrite S3; the normalised shape is persisted on the
next save. Both ways the app reads the configuration for infra apply it:
DeploymentConfigService on its reads, and PulumiService on the raw S3
object it hands to a preview or apply (via normalizeDeploymentConfigGames).
The infra program therefore only ever sees the new shape.
Container port 443/80 on tcp is reserved for the Caddy sidecar
deployment-wide, not just within the game that declares https: true:
checkReservedHttpsPortsAcrossDeployment (@hyveon/shared's
gameServerValidator.ts) rejects ANY game — https or not — declaring
443/80/tcp whenever any game in the deployment has https: true, so a
different, non-HTTPS game can't mark 443/tcp 'internal' and have it leak
public via the Caddy sidecar's unconditional 0.0.0.0/0 ingress on the same
port (security-group ingress rules union).
Env-var token interpolation: ${hyveon.network.*}
A game's environment values (@hyveon/shared's GameServerEnvironmentVariable[])
may embed one of two allow-listed tokens instead of a literal value — see
@hyveon/shared's envTokens.ts. The two tokens resolve at different times,
because one is known at apply time and the other genuinely isn't:
-
`${hyveon.network.public-address}`— resolved at build time, inecs.ts.defineEcs'sresolveGameEnvironmentsubstitutes it with the game's own DNS name,`<game>.<zone>`, while building the container definition — the same hostname@hyveon/lambda-update-dnswill point at this task once it's running. If the token is used but the stripped hosted zone name is empty,resolveGameEnvironmentthrows; this is defense in depth only;defineAllalready validates the hosted zone is non-empty beforedefineEcsever runs. -
`${hyveon.network.public-ipv4}`— left raw in the task definition, resolved at container boot. The task's public IPv4 doesn't exist until afterRunTaskallocates the ENI, so nothing at apply time or atRunTask-call time can supply it. Instead, when any env value carries this token, the game container'sentryPointis overridden to['/bin/sh', '-c', <script>], where<script>is generated byenvTokenWrapper.ts'sbuildIpv4WrapperScript. At container start the script:- Polls
https://checkip.amazonaws.com(wgetfirst, falling back tocurl) up to 30 times, 2 seconds apart, stripping whitespace from the response. exports the discovered IP only into the variables that actually carry the token, splicing it in place of the token text.execs the operator's configuredcommand— never falls through to the image's ownENTRYPOINT/CMD, since the wrapper replaced it.- Exits non-zero without ever reaching step 3 if discovery fails after all 30 attempts, so the task stops instead of starting the game with no public address to advertise.
Overriding
entryPointthis way clears the image's built-in start chain, soGameServerConfig.command(DockerCMD, passed straight intocontainerDefinitions[].command) becomes required whenever the ipv4 token is used — there is nothing else left toexec. The image must provide a POSIX shell environment (BusyBox is sufficient) withwgetorcurlfor the wrapper to run at all. Games that don't use the token getcommandpassed through as-is (when set) with noentryPointoverride — seegameServerConfig.ts's doc oncommand.Injection safety: every operator-supplied string (the IP-echo URL, the command, and env values) is embedded single-quoted (
shellSingleQuote), and every token-bearing variable name must match a shell-identifier pattern before the script is built — both checked inbuildIpv4WrapperScriptitself, not just by the config validator upstream. - Polls
A game whose environment uses neither token gets a byte-identical
containerDefinitions JSON to before this feature existed — no entryPoint
key, and command only appears if the operator set one.
Pinned hostname: hostname-shim init container
Pinning only works for game servers that run on Node.js.
The shim replaces os.hostname() inside a Node process and does nothing for any other runtime, so setting pinnedHostname on a non-Node game provisions the extra container and has no effect.
The feature exists for Node-based games whose license is bound to the hostname (Foundry VTT, for example).
The container hostname changes on every RunTask, and the task definition's hostname parameter is ignored under awsvpc networking, so a hostname-bound license fails verification on every start.
Rather than modify the operator's image, defineEcs makes the game process see a fixed hostname by preloading a small script.
A game with GameServerConfig.pinnedHostname set gets these additions to its {game}-server task definition, and nothing else changes:
| Where | What | Why |
|---|---|---|
volumes | A volume named {game}-hostname-shim with no efsVolumeConfiguration. | With no EFS configuration, Fargate backs it with the task's ephemeral storage. It lives and dies with the task, so no access point or mount target is needed. |
containerDefinitions | A hostname-shim container: essential: false, image public.ecr.aws/docker/library/busybox:1.38.0, the volume mounted read-write at /hyveon/hostname-shim. Its command base64-decodes an embedded copy of the shim into /hyveon/hostname-shim/hostname-shim.js and exits. | The shim is a CommonJS module that reassigns require('node:os').hostname to return the configured value. Base64 means no shell quoting problem whatever the hostname contains. Non-essential, so its normal exit doesn't stop the task. Its logs go to the game's log group under stream prefix hostname-shim. |
Game container dependsOn | { containerName: 'hostname-shim', condition: 'SUCCESS' }. | The game process must not start until the shim file exists. |
Game container mountPoints | The same volume, read-only, at /hyveon/hostname-shim. | The game only reads the shim. |
Game container environment | NODE_OPTIONS begins with --require=/hyveon/hostname-shim/hostname-shim.js. | Node loads the shim before any game code. |
The NODE_OPTIONS merge (mergeNodeOptions) runs after env-token resolution and before the ipv4 boot wrapper is built.
If the operator has no NODE_OPTIONS, the shim flag becomes the value.
If the operator already sets one, there is still exactly one entry: it keeps its position, and its value becomes the shim flag, a space, then the operator's value.
The shim goes first so it is in place before the operator's flags apply, and the operator's flags (and any ${hyveon.*} token the wrapper resolves at boot) survive intact.
What this does not do:
- It declares no new Pulumi resource: the init container, volume, mounts, and environment entry all live inside the existing task definition.
- It declares no
aws.ecs.Service; the no-persistent-Service invariant is untouched. - It leaves the Caddy sidecar definition as it was, for HTTPS games too.
- It changes nothing for games without
pinnedHostname: no init container, volume, mount,dependsOn, orNODE_OPTIONSentry is added.
The shared validator reserves /hyveon/hostname-shim (and anything beneath it) so a game volume's container_path can't collide with the shim mount when pinnedHostname is set. It also rejects a game or a volume named hostname-shim in that case, because either would collide with the init container's name or its {game}-hostname-shim task volume.
The runs table invariant — bootstrap-managed, not Pulumi-managed
The run-history DynamoDB table is not a Pulumi resource. RunRecordService's
approve/apply gates need this table to exist on the very FIRST plan/apply
cycle of a fresh install, before any Pulumi apply has ever succeeded — a
resource this program provisions cannot satisfy that, since a stack only
reports outputs (and therefore could only report this table's name) after
its first successful apply.
Instead, BootstrapService.ensureRunsTable (@hyveon/desktop-main) creates
it directly via @aws-sdk/client-dynamodb at first-run-wizard bootstrap
time, alongside the state/configuration S3 buckets — before any
DeploymentConfig or Pulumi apply exists at all. This is the same pattern
"The DNS invariant, precisely" above
describes for per-game DNS records: a resource whose lifecycle genuinely
can't be gated behind this program's own apply is managed by application
code instead, never by Pulumi.
@hyveon/shared's resolveRunsTableName(projectName, runsTableNameOverride)
is the single source of truth for the table's deterministic name, called
from three places that must never disagree: BootstrapService.ensureRunsTable
(the AWS SDK create call), program.ts's buildStackOutputs (the
runsTableName stack output — a plain config echo now, not a resource-derived
pulumi.Output, unlike every other table-name output), and
RunRecordService/resolveRunRecordStoreConfig's pre-apply fallback (which
reads the persisted DeploymentConfig directly via resolvePreApplyRunsTableName
when no Pulumi stack output is available yet).
Dependencies
| Package | Version | Where |
|---|---|---|
@pulumi/pulumi | 3.255.0 | app/packages/infra, app/packages/desktop-main (exact pin, matches PULUMI_ENGINE_VERSION) |
@pulumi/aws | 7.39.0 | app/packages/infra, app/packages/desktop-main (exact pin, kept identical across both workspaces) |