Skip to content

Commit 3543a22

Browse files
committed
Avoid duplicate Linode resource labels
1 parent 8aac543 commit 3543a22

2 files changed

Lines changed: 45 additions & 2 deletions

File tree

packages/cloud/linode/src/index.test.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
33
import adapter from './index.js';
44

55
afterEach(() => {
6+
vi.restoreAllMocks();
67
vi.unstubAllGlobals();
78
});
89

@@ -109,10 +110,47 @@ describe('Linode cloud adapter', () => {
109110
expect(fetchMock).toHaveBeenCalledTimes(1);
110111
const [, init] = fetchMock.mock.calls[0]!;
111112
const body = JSON.parse(init!.body as string);
112-
expect(body.label).toMatch(/^sh1pt-bs-\d+$/);
113+
expect(body.label).toMatch(/^sh1pt-bs-[a-z0-9]+-[a-z0-9]{4}$/);
113114
expect(body.label.length).toBeLessThanOrEqual(32);
114115
});
115116

117+
it('varies generated labels for same-millisecond block storage creates', async () => {
118+
vi.spyOn(Date, 'now').mockReturnValue(1_800_000_000_000);
119+
vi.spyOn(Math, 'random')
120+
.mockReturnValueOnce(0.111111)
121+
.mockReturnValueOnce(0.222222);
122+
123+
const fetchMock = vi.fn().mockResolvedValue({
124+
ok: true,
125+
status: 200,
126+
text: async () => JSON.stringify({
127+
id: 456,
128+
label: 'sh1pt-bs-test',
129+
status: 'active',
130+
size: 20,
131+
region: 'us-east',
132+
linode_id: null,
133+
created: '2026-06-13T00:00:00',
134+
}),
135+
});
136+
vi.stubGlobal('fetch', fetchMock);
137+
138+
const ctx = {
139+
secret: (key: string) => key === 'LINODE_API_TOKEN' ? 'token' : undefined,
140+
log: vi.fn(),
141+
dryRun: false,
142+
};
143+
144+
await adapter.provision(ctx, { kind: 'block-storage', region: 'us-east' }, {});
145+
await adapter.provision(ctx, { kind: 'block-storage', region: 'us-east' }, {});
146+
147+
const first = JSON.parse(fetchMock.mock.calls[0]![1]!.body as string).label;
148+
const second = JSON.parse(fetchMock.mock.calls[1]![1]!.body as string).label;
149+
expect(first).not.toEqual(second);
150+
expect(first.length).toBeLessThanOrEqual(32);
151+
expect(second.length).toBeLessThanOrEqual(32);
152+
});
153+
116154
it('requires a login mechanism before non-dry-run image provisioning', async () => {
117155
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
118156
ok: true,

packages/cloud/linode/src/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ export default defineCloud<Config>({
131131

132132
async provision(ctx, spec, config) {
133133
const region = spec.region ?? config.defaultRegion ?? DEFAULT_REGION;
134-
const label = `sh1pt-${labelKind(spec.kind)}-${Date.now()}`;
134+
const label = resourceLabel(spec.kind);
135135

136136
if (ctx.dryRun) return { ...stubInstance('dry-run', 'provisioning', spec.kind), region };
137137

@@ -322,6 +322,11 @@ function labelKind(kind: InstanceSpec['kind']): string {
322322
return kind;
323323
}
324324

325+
function resourceLabel(kind: InstanceSpec['kind']): string {
326+
const suffix = Math.random().toString(36).slice(2, 6).padEnd(4, '0');
327+
return `sh1pt-${labelKind(kind)}-${Date.now().toString(36)}-${suffix}`;
328+
}
329+
325330
function pickType(types: LinodeType[], spec: InstanceSpec, region: string): LinodeType | null {
326331
let candidates = types.filter((type) => regionAvailable(type.region_availability, region));
327332

0 commit comments

Comments
 (0)