also looking at this
fix(operations): the inventory read a location Hetzner no longer sends
#193
2 files
+69
-2
| @@ -63,6 +63,26 @@ describe('shaping a provider server', () => { | ||
| 63 | 63 | .toMatchObject({ ipv4: '1.2.3.4', type: 'medium', location: 'nbg1' }) |
| 64 | 64 | }) |
| 65 | 65 | |
| 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 | ||
| 66 | 86 | it('keeps an unlabelled box rather than dropping it', () => { |
| 67 | 87 | // A box provisioned by hand, or by a ts-cloud old enough not to label, is |
| 68 | 88 | // exactly the kind a consolidation needs to see. |
| @@ -79,6 +79,53 @@ function text(value: unknown): string | undefined { | ||
| 79 | 79 | return typeof value === 'string' && value.trim() ? value.trim() : undefined |
| 80 | 80 | } |
| 81 | 81 | |
| 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 | */ | |
| 91 | export 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 | */ | |
| 121 | function 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 | ||
| 82 | 129 | /** |
| 83 | 130 | * Shape one provider server record into {@link InventoryServer}. |
| 84 | 131 | * |
| @@ -90,7 +137,7 @@ function text(value: unknown): string | undefined { | ||
| 90 | 137 | * or by a ts-cloud old enough not to have labelled it, is exactly the kind a |
| 91 | 138 | * consolidation needs to see; requiring the labels would hide it. |
| 92 | 139 | */ |
| 93 | export function toInventoryServer(raw: any): InventoryServer { | |
| 140 | export function toInventoryServer(raw: ProviderServerPayload | null | undefined): InventoryServer { | |
| 94 | 141 | const labels: Record<string, string> = {} |
| 95 | 142 | for (const [key, value] of Object.entries(raw?.labels ?? {})) { |
| 96 | 143 | if (typeof value === 'string') labels[key] = value |
| @@ -103,7 +150,7 @@ export function toInventoryServer(raw: any): InventoryServer { | ||
| 103 | 150 | ipv4: text(raw?.public_net?.ipv4?.ip) ?? text(raw?.ipv4), |
| 104 | 151 | ipv6: text(raw?.public_net?.ipv6?.ip) ?? text(raw?.ipv6), |
| 105 | 152 | 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), | |
| 107 | 154 | labels, |
| 108 | 155 | project: text(labels[PROJECT_LABEL]), |
| 109 | 156 | environment: text(labels[ENVIRONMENT_LABEL]), |