ReviewOS

also looking at this

stacks/ts-cloud

fix(operations): the inventory read a location Hetzner no longer sends

#193
Merged glennmichael123 wants to merge fix/inventory-provider-payload-type into main
2 files +69 -2
packages/ts-cloud/src/operations/inventory.test.tsmodified+20-0
Changes to packages/ts-cloud/src/operations/inventory.test.ts
@@ -63,6 +63,26 @@ describe('shaping a provider server', () => {
6363 .toMatchObject({ ipv4: '1.2.3.4', type: 'medium', location: 'nbg1' })
6464 })
6565
66 it('reads the location off the shape the API actually returns today', () => {
67 // The regression this guards. Hetzner retired `datacenter` and nests the
68 // place under `location`, which this read as a bare string - so every live
69 // box reported no location at all. `resize.ts` and `role-swap.ts` already
70 // carry the same fallback; this is the third reader to need it.
71 expect(toInventoryServer({
72 id: 3,
73 name: 'stacks-production-app',
74 status: 'running',
75 datacenter: null,
76 location: { id: 1, name: 'fsn1', city: 'Falkenstein' },
77 labels: {},
78 })).toMatchObject({ location: 'fsn1' })
79 })
80
81 it('still prefers the legacy datacenter when a recorded fixture carries one', () => {
82 expect(toInventoryServer({ id: 4, name: 'box', status: 'running', datacenter: { name: 'fsn1-dc14' }, labels: {} }))
83 .toMatchObject({ location: 'fsn1-dc14' })
84 })
85
6686 it('keeps an unlabelled box rather than dropping it', () => {
6787 // A box provisioned by hand, or by a ts-cloud old enough not to label, is
6888 // exactly the kind a consolidation needs to see.
packages/ts-cloud/src/operations/inventory.tsmodified+49-2
Changes to packages/ts-cloud/src/operations/inventory.ts
@@ -79,6 +79,53 @@ function text(value: unknown): string | undefined {
7979 return typeof value === 'string' && value.trim() ? value.trim() : undefined
8080}
8181
82/**
83 * One provider server record, as a listing returns it.
84 *
85 * Every field is optional and `unknown` because this is JSON from an external
86 * API: `text()` is what turns each one into a string or nothing. The nested
87 * fields are Hetzner's spelling; the flat ones beside them are the fallback a
88 * driver with a simpler listing can answer with, which is what lets one type
89 * cover more than one provider.
90 */
91export interface ProviderServerPayload {
92 id?: unknown
93 name?: unknown
94 status?: unknown
95 labels?: Record<string, unknown>
96 public_net?: { ipv4?: { ip?: unknown } | null, ipv6?: { ip?: unknown } | null } | null
97 server_type?: { name?: unknown } | null
98 /**
99 * Legacy shape. Hetzner has stopped returning this and sends an explicit
100 * `null` in its place, which is why the null belongs in the type: it is what
101 * a live listing contains today. See `location`.
102 */
103 datacenter?: { name?: unknown, location?: { name?: unknown } | null } | null
104 ipv4?: unknown
105 ipv6?: unknown
106 type?: unknown
107 /**
108 * Current Hetzner shape is an object; a simpler driver may answer a bare
109 * string. {@link placeName} reads both.
110 */
111 location?: unknown
112}
113
114/**
115 * A place name that arrives either as `{ name }` or as a bare string.
116 *
117 * Hetzner's current listing nests it and its retired `datacenter` field did
118 * too, so reading only the string form reported no location at all for every
119 * live box. `resize.ts` and `role-swap.ts` already carry the same fallback.
120 */
121function placeName(value: unknown): string | undefined {
122 if (typeof value === 'string')
123 return text(value)
124 if (value && typeof value === 'object')
125 return text((value as { name?: unknown }).name)
126 return undefined
127}
128
82129/**
83130 * Shape one provider server record into {@link InventoryServer}.
84131 *
@@ -90,7 +137,7 @@ function text(value: unknown): string | undefined {
90137 * or by a ts-cloud old enough not to have labelled it, is exactly the kind a
91138 * consolidation needs to see; requiring the labels would hide it.
92139 */
93export function toInventoryServer(raw: any): InventoryServer {
140export function toInventoryServer(raw: ProviderServerPayload | null | undefined): InventoryServer {
94141 const labels: Record<string, string> = {}
95142 for (const [key, value] of Object.entries(raw?.labels ?? {})) {
96143 if (typeof value === 'string') labels[key] = value
@@ -103,7 +150,7 @@ export function toInventoryServer(raw: any): InventoryServer {
103150 ipv4: text(raw?.public_net?.ipv4?.ip) ?? text(raw?.ipv4),
104151 ipv6: text(raw?.public_net?.ipv6?.ip) ?? text(raw?.ipv6),
105152 type: text(raw?.server_type?.name) ?? text(raw?.type),
106 location: text(raw?.datacenter?.location?.name) ?? text(raw?.datacenter?.name) ?? text(raw?.location),
153 location: placeName(raw?.location) ?? placeName(raw?.datacenter?.location) ?? placeName(raw?.datacenter),
107154 labels,
108155 project: text(labels[PROJECT_LABEL]),
109156 environment: text(labels[ENVIRONMENT_LABEL]),