also looking at this
feat(consolidation): report an attach's credential radius, and rename a server in place
#175
12 files
+1138
-14
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -149,9 +149,32 @@ own provider project cannot be attached at all, because its token cannot see the | ||
| 149 | 149 | owner's box. Co-hosting trades credential isolation for a shared box; that trade |
| 150 | 150 | is often worth making, but it should be a decision rather than a surprise. |
| 151 | 151 | |
| 152 | `describeCredentialReach()` and `formatCredentialReach()` report what a given | |
| 153 | token actually reaches, separating the owner's boxes from the ones no one asked | |
| 154 | for, so the radius can be shown before an attach is approved. | |
| 152 | Every attach deploy states this radius before it acts on it, splitting the | |
| 153 | owner's boxes from the ones nobody asked for: | |
| 154 | ||
| 155 | ``` | |
| 156 | Attaching to 'statushq' shares one provider project, so this deploy's credential | |
| 157 | can write to all 4 server(s) it can see. | |
| 158 | 3 of them belong to neither project: | |
| 159 | bughq: bughq-production-app | |
| 160 | stacks: stacks-production-app | |
| 161 | not managed by ts-cloud: some-legacy-box | |
| 162 | A compromised CI run or a mistargeted teardown in this project now reaches those. | |
| 163 | Keep the app in its own provider project instead if that is not acceptable, which | |
| 164 | rules out attaching. | |
| 165 | ``` | |
| 166 | ||
| 167 | It is reported, never enforced — the trade is frequently worth making, and a | |
| 168 | deploy that started failing on upgrade would teach operators to silence it | |
| 169 | rather than read it. When the reach is exactly the two projects being joined it | |
| 170 | is one quiet line, because a warning that fires every time is a warning nobody | |
| 171 | reads. | |
| 172 | ||
| 173 | The radius comes from the driver (`CloudDriver.listReachableResources()`), not | |
| 174 | from a global assumption about tokens: a provider whose credential can be | |
| 175 | scoped per-resource simply enumerates less, and the same report comes out | |
| 176 | correct without a special case. `describeCredentialReach()` and | |
| 177 | `formatCredentialReach()` are exported for building your own plan output. | |
| 155 | 178 | |
| 156 | 179 | ### It cannot install services on the owner's box |
| 157 | 180 | |
| @@ -134,6 +134,37 @@ export interface CloudDriver { | ||
| 134 | 134 | |
| 135 | 135 | /** Run a shell script on every target (SSM, SSH, etc.) */ |
| 136 | 136 | runRemoteDeploy(options: RunRemoteDeployOptions): Promise<RemoteDeployResult> |
| 137 | ||
| 138 | /** | |
| 139 | * Enumerate every resource this driver's credential can see — and therefore, | |
| 140 | * on providers without per-resource scoping, write to and delete. | |
| 141 | * | |
| 142 | * Exists so nothing above the driver has to assume "one all-powerful token" | |
| 143 | * is the only credential shape. Attaching to another project's box works by | |
| 144 | * LISTING the provider with the attaching project's own credential, so the | |
| 145 | * radius is a property of that credential rather than of the config, and only | |
| 146 | * the driver holding it can report it. A driver whose credential IS narrowly | |
| 147 | * scoped simply enumerates less, and the same reporting comes out right | |
| 148 | * without a special case. | |
| 149 | * | |
| 150 | * Optional: a driver that cannot enumerate omits it, and callers report no | |
| 151 | * radius rather than a wrong one. | |
| 152 | * | |
| 153 | * @see https://github.com/stacksjs/ts-cloud/issues/169 | |
| 154 | */ | |
| 155 | listReachableResources?(): Promise<ReachableResource[]> | |
| 156 | } | |
| 157 | ||
| 158 | /** | |
| 159 | * One resource a provider credential can reach, reduced to what attribution | |
| 160 | * needs: a name to print and the labels that say who owns it. | |
| 161 | * | |
| 162 | * Deliberately structural and provider-agnostic — a Hetzner server satisfies it | |
| 163 | * as-is, and another driver can satisfy it without importing anything. | |
| 164 | */ | |
| 165 | export interface ReachableResource { | |
| 166 | name: string | |
| 167 | labels?: Record<string, string> | |
| 137 | 168 | } |
| 138 | 169 | |
| 139 | 170 | export interface DeploySiteReleaseOptions { |
| @@ -1,3 +1,4 @@ | ||
| 1 | import type { ReachableResource } from '@ts-cloud/core' | |
| 1 | 2 | import { TS_CLOUD_LABEL_PREFIX } from '../drivers/hetzner/instance-sizes' |
| 2 | 3 | |
| 3 | 4 | /** |
| @@ -28,15 +29,10 @@ import { TS_CLOUD_LABEL_PREFIX } from '../drivers/hetzner/instance-sizes' | ||
| 28 | 29 | const PROJECT_LABEL = `${TS_CLOUD_LABEL_PREFIX}/project` |
| 29 | 30 | |
| 30 | 31 | /** |
| 31 | * The minimum a driver has to produce for a server to be attributed. | |
| 32 | * | |
| 33 | * Structural rather than a provider type so a Hetzner server satisfies it as-is | |
| 34 | * and another driver can satisfy it without importing anything. | |
| 32 | * The minimum a driver has to produce for a server to be attributed — the shape | |
| 33 | * `CloudDriver.listReachableResources()` returns. | |
| 35 | 34 | */ |
| 36 | export interface ReachableServer { | |
| 37 | name: string | |
| 38 | labels?: Record<string, string> | |
| 39 | } | |
| 35 | export type ReachableServer = ReachableResource | |
| 40 | 36 | |
| 41 | 37 | export interface CredentialReach { |
| 42 | 38 | /** Every server the credential enumerated. */ |
| @@ -1,4 +1,4 @@ | ||
| 1 | import type { CloudDriver, ComputeProxyConfig, ComputeStackOutputs, ComputeTarget, FindComputeTargetsOptions, ProvisionComputeOptions, RemoteDeployResult, RunRemoteDeployOptions, SiteConfig, UploadReleaseOptions, UploadReleaseResult } from '@ts-cloud/core' | |
| 1 | import type { CloudDriver, ComputeProxyConfig, ComputeStackOutputs, ComputeTarget, FindComputeTargetsOptions, ProvisionComputeOptions, ReachableResource, RemoteDeployResult, RunRemoteDeployOptions, SiteConfig, UploadReleaseOptions, UploadReleaseResult } from '@ts-cloud/core' | |
| 2 | 2 | import type { RpxLbAppBox } from '../shared/rpx-gateway' |
| 3 | 3 | import type { HetznerFirewall, HetznerFirewallRule, HetznerServer } from './client' |
| 4 | 4 | import type { HetznerDriverState } from './state' |
| @@ -1239,6 +1239,17 @@ export class HetznerDriver implements CloudDriver { | ||
| 1239 | 1239 | return this.outputsFromState(state) |
| 1240 | 1240 | } |
| 1241 | 1241 | |
| 1242 | /** | |
| 1243 | * Every server this token can see. On Hetzner that is every server in the | |
| 1244 | * provider project, because a Cloud API token is project-scoped with Read or | |
| 1245 | * Read & Write and no per-resource scoping — so "can see" and "can delete" | |
| 1246 | * are the same set, and the count is the honest blast radius. | |
| 1247 | */ | |
| 1248 | async listReachableResources(): Promise<ReachableResource[]> { | |
| 1249 | const servers = await this.client.listServers() | |
| 1250 | return servers.map((server) => ({ name: server.name, labels: server.labels })) | |
| 1251 | } | |
| 1252 | ||
| 1242 | 1253 | async runRemoteDeploy(options: RunRemoteDeployOptions): Promise<RemoteDeployResult> { |
| 1243 | 1254 | if (options.targets.length === 0) { |
| 1244 | 1255 | return { success: false, instanceCount: 0, perInstance: [], error: 'No targets provided' } |
| @@ -1100,3 +1100,117 @@ describe('deployAllComputeSites attach-mode service preflight', () => { | ||
| 1100 | 1100 | expect(commands).not.toContain('ts_cloud_probe') |
| 1101 | 1101 | }) |
| 1102 | 1102 | }) |
| 1103 | ||
| 1104 | /** | |
| 1105 | * Attaching resolves the owner's box by LISTING the provider with the ATTACHING | |
| 1106 | * project's credential, so the owner's box must be visible to it — which on a | |
| 1107 | * provider without per-resource scoping means write over every server in the | |
| 1108 | * project. Reported so it is a decision rather than a discovery. | |
| 1109 | */ | |
| 1110 | describe('deployAllComputeSites attach-mode credential reach', () => { | |
| 1111 | function attachedConfig(): CloudConfig { | |
| 1112 | return { | |
| 1113 | project: { name: 'Log HQ', slug: 'loghq', region: 'fsn1' }, | |
| 1114 | environments: { production: { type: 'production' } }, | |
| 1115 | cloud: { provider: 'hetzner', attachTo: 'statushq' }, | |
| 1116 | sites: { web: { domain: 'loghq.example.com', port: 3000, root: '.output', start: 'bun run server.ts' } }, | |
| 1117 | infrastructure: { compute: { runtime: 'bun', proxy: { engine: 'rpx' } } }, | |
| 1118 | } | |
| 1119 | } | |
| 1120 | ||
| 1121 | async function deployWith(reachable: Array<{ name: string, labels?: Record<string, string> }> | Error) { | |
| 1122 | const warnings: string[] = [] | |
| 1123 | const infos: string[] = [] | |
| 1124 | const driver = createMockDriver({ | |
| 1125 | name: 'hetzner', | |
| 1126 | usesCloudFormation: false, | |
| 1127 | listReachableResources: mock(async () => { | |
| 1128 | if (reachable instanceof Error) throw reachable | |
| 1129 | return reachable | |
| 1130 | }), | |
| 1131 | }) | |
| 1132 | const tempDir = mkdtempSync(join(tmpdir(), 'ts-cloud-reach-')) | |
| 1133 | const tarball = join(tempDir, 'release.tar.gz') | |
| 1134 | writeFileSync(tarball, 'fake tarball') | |
| 1135 | process.env.TS_CLOUD_UI_DISABLE = '1' | |
| 1136 | const ok = await deployAllComputeSites({ | |
| 1137 | config: attachedConfig(), | |
| 1138 | environment: 'production', | |
| 1139 | driver, | |
| 1140 | sha: 'abc', | |
| 1141 | runtime: 'bun', | |
| 1142 | tarballForSite: () => tarball, | |
| 1143 | logger: { | |
| 1144 | info: (message: string) => infos.push(message), | |
| 1145 | warn: (message: string) => warnings.push(message), | |
| 1146 | error: () => {}, | |
| 1147 | step: () => {}, | |
| 1148 | success: () => {}, | |
| 1149 | }, | |
| 1150 | }).finally(() => { | |
| 1151 | delete process.env.TS_CLOUD_UI_DISABLE | |
| 1152 | rmSync(tempDir, { recursive: true, force: true }) | |
| 1153 | }) | |
| 1154 | return { ok, warnings: warnings.join('\n'), infos: infos.join('\n') } | |
| 1155 | } | |
| 1156 | ||
| 1157 | const label = (project: string) => ({ 'ts-cloud/project': project }) | |
| 1158 | ||
| 1159 | it('warns, naming the servers neither project owns', async () => { | |
| 1160 | const { ok, warnings } = await deployWith([ | |
| 1161 | { name: 'statushq-production-app', labels: label('statushq') }, | |
| 1162 | { name: 'bughq-production-app', labels: label('bughq') }, | |
| 1163 | { name: 'stacks-production-app', labels: label('stacks') }, | |
| 1164 | { name: 'some-legacy-box' }, | |
| 1165 | ]) | |
| 1166 | expect(ok).toBe(true) | |
| 1167 | expect(warnings).toContain('all 4 server(s)') | |
| 1168 | expect(warnings).toContain('bughq: bughq-production-app') | |
| 1169 | expect(warnings).toContain('stacks: stacks-production-app') | |
| 1170 | expect(warnings).toContain('not managed by ts-cloud: some-legacy-box') | |
| 1171 | }) | |
| 1172 | ||
| 1173 | /** | |
| 1174 | * A warning that fires every time is a warning nobody reads: when the reach is | |
| 1175 | * exactly the two projects being joined there is nothing to decide. | |
| 1176 | */ | |
| 1177 | it('stays quiet when the reach is only the two projects being joined', async () => { | |
| 1178 | const { ok, warnings, infos } = await deployWith([ | |
| 1179 | { name: 'statushq-production-app', labels: label('statushq') }, | |
| 1180 | { name: 'loghq-production-app', labels: label('loghq') }, | |
| 1181 | ]) | |
| 1182 | expect(ok).toBe(true) | |
| 1183 | expect(warnings).toBe('') | |
| 1184 | expect(infos).toContain('Nothing outside the two projects being joined is reachable with it.') | |
| 1185 | }) | |
| 1186 | ||
| 1187 | it('never fails the deploy when the credential cannot enumerate', async () => { | |
| 1188 | const { ok, warnings } = await deployWith(new Error('403 forbidden')) | |
| 1189 | expect(ok).toBe(true) | |
| 1190 | expect(warnings).toContain('403 forbidden') | |
| 1191 | }) | |
| 1192 | ||
| 1193 | it('reports nothing for a driver that cannot enumerate at all', async () => { | |
| 1194 | const driver = createMockDriver({ name: 'hetzner', usesCloudFormation: false }) | |
| 1195 | expect(driver.listReachableResources).toBeUndefined() | |
| 1196 | const warnings: string[] = [] | |
| 1197 | const tempDir = mkdtempSync(join(tmpdir(), 'ts-cloud-reach-')) | |
| 1198 | const tarball = join(tempDir, 'release.tar.gz') | |
| 1199 | writeFileSync(tarball, 'fake tarball') | |
| 1200 | process.env.TS_CLOUD_UI_DISABLE = '1' | |
| 1201 | const ok = await deployAllComputeSites({ | |
| 1202 | config: attachedConfig(), | |
| 1203 | environment: 'production', | |
| 1204 | driver, | |
| 1205 | sha: 'abc', | |
| 1206 | runtime: 'bun', | |
| 1207 | tarballForSite: () => tarball, | |
| 1208 | logger: { info: () => {}, warn: (m: string) => warnings.push(m), error: () => {}, step: () => {}, success: () => {} }, | |
| 1209 | }).finally(() => { | |
| 1210 | delete process.env.TS_CLOUD_UI_DISABLE | |
| 1211 | rmSync(tempDir, { recursive: true, force: true }) | |
| 1212 | }) | |
| 1213 | expect(ok).toBe(true) | |
| 1214 | expect(warnings.join('\n')).not.toContain('credential') | |
| 1215 | }) | |
| 1216 | }) | |