From 7f60eb80221b94e5da57cf7ea5f89aadcadf2e23 Mon Sep 17 00:00:00 2001 From: Fuxing Loh <4266087+fuxingloh@users.noreply.github.com> Date: Fri, 21 Jun 2024 18:35:12 +0800 Subject: [PATCH] feat(cd8ks): volume expansion support (#126) --- package.json | 2 +- .../src/__snapshots__/chart.test.ts.snap | 2 +- packages/chainfile-cdk8s/src/chart.test.ts | 6 + packages/chainfile-cdk8s/src/sts.ts | 34 +--- packages/chainfile-cdk8s/src/volume.test.ts | 185 ++++++++++++++++++ packages/chainfile-cdk8s/src/volume.ts | 94 +++++++++ pnpm-lock.yaml | 84 ++++---- 7 files changed, 338 insertions(+), 69 deletions(-) create mode 100644 packages/chainfile-cdk8s/src/volume.test.ts create mode 100644 packages/chainfile-cdk8s/src/volume.ts diff --git a/package.json b/package.json index a1ed223..3704d0b 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ }, "prettier": "@workspace/prettier-config", "devDependencies": { - "@types/node": "^20.14.6", + "@types/node": "^20.14.7", "@workspace/eslint-config": "workspace:*", "@workspace/jest": "workspace:*", "@workspace/prettier-config": "workspace:*", diff --git a/packages/chainfile-cdk8s/src/__snapshots__/chart.test.ts.snap b/packages/chainfile-cdk8s/src/__snapshots__/chart.test.ts.snap index 940ff74..8b71c8f 100644 --- a/packages/chainfile-cdk8s/src/__snapshots__/chart.test.ts.snap +++ b/packages/chainfile-cdk8s/src/__snapshots__/chart.test.ts.snap @@ -190,7 +190,7 @@ exports[`bitcoin_mainnet.json should synth bitcoin_mainnet.json and match snapsh ], "resources": { "requests": { - "storage": "600Gi", + "storage": "737280Mi", }, }, }, diff --git a/packages/chainfile-cdk8s/src/chart.test.ts b/packages/chainfile-cdk8s/src/chart.test.ts index 38542c6..e31f93b 100644 --- a/packages/chainfile-cdk8s/src/chart.test.ts +++ b/packages/chainfile-cdk8s/src/chart.test.ts @@ -9,6 +9,12 @@ import getPort from 'get-port'; import { version } from '../package.json'; import { CFChart } from './chart'; +global.Date.now = jest.fn(() => new Date('2024-01-01T00:00:00Z').getTime()); + +afterAll(() => { + jest.restoreAllMocks(); +}); + const bitcoin_mainnet: Chainfile = { caip2: 'bip122:000000000019d6689c085ae165831e93', name: 'Bitcoin Mainnet', diff --git a/packages/chainfile-cdk8s/src/sts.ts b/packages/chainfile-cdk8s/src/sts.ts index fd0f1ed..802fa21 100644 --- a/packages/chainfile-cdk8s/src/sts.ts +++ b/packages/chainfile-cdk8s/src/sts.ts @@ -1,17 +1,11 @@ import * as schema from '@chainfile/schema'; import { Names } from 'cdk8s'; -import { - KubeStatefulSet, - ObjectMeta, - PodSpec, - PodTemplateSpec, - Quantity, - StatefulSetSpec, -} from 'cdk8s-plus-25/lib/imports/k8s'; +import { KubeStatefulSet, ObjectMeta, PodSpec, PodTemplateSpec, StatefulSetSpec } from 'cdk8s-plus-25/lib/imports/k8s'; import { Construct } from 'constructs'; import { CFAgent, CFContainer } from './container'; import { CFParamsSource } from './params'; +import { CFEphemeralVolume, CFPersistentVolumeClaimSpec } from './volume'; export interface CFStatefulSetProps { readonly chainfile: schema.Chainfile; @@ -49,12 +43,10 @@ export class CFStatefulSet extends KubeStatefulSet { volumes: Object.entries(volumes) .filter(([, volume]) => volume.type === 'ephemeral') .map(([volumeName, volume]) => { - return { + return CFEphemeralVolume({ name: volumeName, - emptyDir: { - sizeLimit: Quantity.fromString(volume.size), - }, - }; + volume: volume, + }); }), containers: [ CFAgent({ params: props.params, chainfile: props.chainfile }), @@ -68,9 +60,7 @@ export class CFStatefulSet extends KubeStatefulSet { return mount.volume; } - return Names.toDnsLabel(scope, { - extra: ['pvc', mount.volume], - }); + return Names.toDnsLabel(scope, { extra: ['pvc', mount.volume] }); }, }); }), @@ -84,15 +74,9 @@ export class CFStatefulSet extends KubeStatefulSet { metadata: { name: Names.toDnsLabel(scope, { extra: ['pvc', volumeName] }), }, - spec: { - accessModes: ['ReadWriteOnce'], - resources: { - requests: { - // TODO(?): expansion support, - storage: Quantity.fromString(volume.size), - }, - }, - }, + spec: CFPersistentVolumeClaimSpec({ + volume: volume, + }), }; }), }, diff --git a/packages/chainfile-cdk8s/src/volume.test.ts b/packages/chainfile-cdk8s/src/volume.test.ts new file mode 100644 index 0000000..ddc1aa3 --- /dev/null +++ b/packages/chainfile-cdk8s/src/volume.test.ts @@ -0,0 +1,185 @@ +import * as schema from '@chainfile/schema'; +import { describe, expect, it } from '@workspace/jest/globals'; + +import { calculateStorage } from './volume'; + +it('should calculate volume without expansion', async () => { + const volume: schema.Volume = { + type: 'persistent', + size: '100Gi', + }; + + expect( + calculateStorage(volume, { + min: 3, + max: 6, + today: new Date('2021-09-01'), + }), + ).toEqual({ value: '102400Mi' }); +}); + +describe('expansion', () => { + it.each([ + { + size: '10000Mi', + rate: '10Mi', + startFrom: '2024-01-01', + min: 1, + max: 12, + today: '2024-01-01', + expected: '10220Mi', + }, + { + size: '10000Mi', + rate: '10Mi', + startFrom: '2024-01-01', + min: 3, + max: 9, + today: '2024-06-01', + expected: '10180Mi', + }, + { + size: '10000Mi', + rate: '10Mi', + startFrom: '2024-01-01', + min: 3, + max: 6, + today: '2024-06-01', + expected: '10120Mi', + }, + { + size: '10000Mi', + rate: '10Mi', + startFrom: '2024-01-01', + min: 3, + max: 6, + today: '2024-07-01', + expected: '10120Mi', + }, + { + size: '10000Mi', + rate: '10Mi', + startFrom: '2024-01-01', + min: 3, + max: 6, + today: '2024-08-01', + expected: '10150Mi', + }, + { + size: '10000Mi', + rate: '10Mi', + startFrom: '2024-01-01', + min: 3, + max: 6, + today: '2024-09-01', + expected: '10150Mi', + }, + { + size: '10000Mi', + rate: '10Mi', + startFrom: '2024-01-01', + min: 3, + max: 6, + today: '2024-10-01', + expected: '10150Mi', + }, + { + size: '10000Mi', + rate: '10Mi', + startFrom: '2024-01-01', + min: 3, + max: 6, + today: '2024-11-01', + expected: '10180Mi', + }, + { + size: '10000Mi', + rate: '10Mi', + startFrom: '2024-01-01', + min: 4, + max: 6, + today: '2024-09-01', + expected: '10140Mi', + }, + { + size: '10000Mi', + rate: '10Mi', + startFrom: '2024-01-01', + min: 6, + max: 9, + today: '2024-08-01', + expected: '10180Mi', + }, + { + size: '10000Mi', + rate: '10Mi', + startFrom: '2024-01-01', + min: 6, + max: 9, + today: '2024-09-01', + expected: '10180Mi', + }, + { + size: '10000Mi', + rate: '10Mi', + startFrom: '2024-01-01', + min: 6, + max: 9, + today: '2024-10-01', + expected: '10180Mi', + }, + { + size: '10000Mi', + rate: '10Mi', + startFrom: '2024-01-01', + min: 6, + max: 9, + today: '2024-11-01', + expected: '10210Mi', + }, + { + size: '10000Mi', + rate: '10Mi', + startFrom: '2024-01-01', + min: 6, + max: 9, + today: '2024-12-01', + expected: '10210Mi', + }, + { + size: '10000Mi', + rate: '10Mi', + startFrom: '2024-01-01', + min: 6, + max: 9, + today: '2025-01-01', + expected: '10210Mi', + }, + { + size: '10000Mi', + rate: '10Mi', + startFrom: '2024-01-01', + min: 6, + max: 9, + today: '2025-02-01', + expected: '10240Mi', + }, + ])('should calculate volume with expansion %p', async ({ size, rate, startFrom, min, max, today, expected }) => { + const volume: schema.Volume = { + type: 'persistent', + size: size, + expansion: { + startFrom: startFrom, + monthlyRate: rate, + }, + }; + + expect( + calculateStorage(volume, { + min: min, + max: max, + today: new Date(today), + }), + ).toEqual({ value: expected }); + }); +}); diff --git a/packages/chainfile-cdk8s/src/volume.ts b/packages/chainfile-cdk8s/src/volume.ts new file mode 100644 index 0000000..4babf17 --- /dev/null +++ b/packages/chainfile-cdk8s/src/volume.ts @@ -0,0 +1,94 @@ +import * as schema from '@chainfile/schema'; +import { PersistentVolumeClaimSpec, Quantity, Volume } from 'cdk8s-plus-25/lib/imports/k8s'; + +export function CFEphemeralVolume(props: { name: string; volume: schema.Volume }): Volume { + return { + name: props.name, + emptyDir: { + sizeLimit: calculateStorage(props.volume, { + min: 3, + max: 6, + }), + }, + }; +} + +export function CFPersistentVolumeClaimSpec(props: { volume: schema.Volume }): PersistentVolumeClaimSpec { + return { + accessModes: ['ReadWriteOnce'], + resources: { + requests: { + storage: calculateStorage(props.volume, { + min: 3, + max: 6, + }), + }, + }, + }; +} + +type SizeUnit = 'M' | 'G' | 'T'; + +/** + * Determines the storage size for a volume based on its configuration. + * + * If the volume has an expansion policy, the size is calculated based on the monthly rate. + * The `options.min` and `options.max` parameter defines the boundary of the volume in monthly intervals. + * To reduce drift, the volume size is rounded up to the next interval (interval=max-min). + * This means that the volume size will be at least `options.min` and at most `options.max` after the first month. + * + * Although the volume can technically shrink when the schema changes. + * Volume shrinking is not supported by most storage providers which will result in "kubectl apply" error. + */ +export function calculateStorage( + volume: schema.Volume, + options: { + min: number; + max: number; + today?: Date; + }, +): Quantity { + const sizeMi = parseSizeAsMi(volume.size); + + if (volume.expansion) { + const today = options.today ?? new Date(Date.now()); + const startFrom = new Date(volume.expansion.startFrom); + if (today >= startFrom) { + const monthsSince = + (today.getFullYear() - startFrom.getFullYear()) * 12 + (today.getMonth() - startFrom.getMonth()); + const interval = options.max - options.min; + const monthsSinceRounded = Math.ceil((monthsSince + options.max) / interval) * interval; + const growthRateMi = parseSizeAsMi(volume.expansion.monthlyRate); + + const finalMi = sizeMi + growthRateMi * Math.max(0, monthsSinceRounded); + return Quantity.fromString(`${finalMi}Mi`); + } + } + + return Quantity.fromString(`${sizeMi}Mi`); +} + +function parseSizeAsMi(size: string): number { + const match = size.match(/^(\d+)([MGT])i$/); + if (!match) { + throw new Error(`Invalid size format: ${size}`); + } + + const value = parseInt(match[1], 10); + const unit = match[2] as SizeUnit; + + return convertToMi(value, unit); +} + +function convertToMi(value: number, unit: SizeUnit): number { + switch (unit) { + case 'M': + return value; + case 'G': + return value * 1024; + case 'T': + return value * 1024 * 1024; + default: + throw new Error(`Invalid unit: ${unit}`); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8842a9d..5f3ad3a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: devDependencies: '@types/node': - specifier: ^20.14.6 - version: 20.14.6 + specifier: ^20.14.7 + version: 20.14.7 '@workspace/eslint-config': specifier: workspace:* version: link:workspace/eslint-config @@ -31,7 +31,7 @@ importers: version: 9.0.11 jest: specifier: 29.7.0 - version: 29.7.0(@types/node@20.14.6) + version: 29.7.0(@types/node@20.14.7) lint-staged: specifier: ^15.2.7 version: 15.2.7 @@ -1036,8 +1036,8 @@ packages: '@types/node@18.19.37': resolution: {integrity: sha512-Pi53fdVMk7Ig5IfAMltQQMgtY7xLzHaEous8IQasYsdQbYK3v90FkxI3XYQCe/Qme58pqp14lXJIsFmGP8VoZQ==} - '@types/node@20.14.6': - resolution: {integrity: sha512-JbA0XIJPL1IiNnU7PFxDXyfAwcwVVrOoqyzzyQTyMeVhBzkJVMSkC1LlVsRQ2lpqiY4n6Bb9oCS6lzDKVQxbZw==} + '@types/node@20.14.7': + resolution: {integrity: sha512-uTr2m2IbJJucF3KUxgnGOZvYbN0QgkGyWxG6973HCpMYFy2KfcgYuIwkJQMQkt1VbBMlvWRbpshFTLxnxCZjKQ==} '@types/prop-types@15.7.12': resolution: {integrity: sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==} @@ -5326,7 +5326,7 @@ snapshots: '@jest/console@29.7.0': dependencies: '@jest/types': 29.6.3 - '@types/node': 20.14.6 + '@types/node': 20.14.7 chalk: 4.1.2 jest-message-util: 29.7.0 jest-util: 29.7.0 @@ -5339,14 +5339,14 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.14.6 + '@types/node': 20.14.7 ansi-escapes: 4.3.2 chalk: 4.1.2 ci-info: 3.9.0 exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@20.14.6) + jest-config: 29.7.0(@types/node@20.14.7) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -5375,7 +5375,7 @@ snapshots: dependencies: '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.14.6 + '@types/node': 20.14.7 jest-mock: 29.7.0 '@jest/expect-utils@29.7.0': @@ -5393,7 +5393,7 @@ snapshots: dependencies: '@jest/types': 29.6.3 '@sinonjs/fake-timers': 10.3.0 - '@types/node': 20.14.6 + '@types/node': 20.14.7 jest-message-util: 29.7.0 jest-mock: 29.7.0 jest-util: 29.7.0 @@ -5415,7 +5415,7 @@ snapshots: '@jest/transform': 29.7.0 '@jest/types': 29.6.3 '@jridgewell/trace-mapping': 0.3.25 - '@types/node': 20.14.6 + '@types/node': 20.14.7 chalk: 4.1.2 collect-v8-coverage: 1.0.2 exit: 0.1.2 @@ -5485,7 +5485,7 @@ snapshots: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 20.14.6 + '@types/node': 20.14.7 '@types/yargs': 17.0.32 chalk: 4.1.2 @@ -5850,11 +5850,11 @@ snapshots: '@types/body-parser@1.19.5': dependencies: '@types/connect': 3.4.38 - '@types/node': 20.14.6 + '@types/node': 20.14.7 '@types/connect@3.4.38': dependencies: - '@types/node': 20.14.6 + '@types/node': 20.14.7 '@types/d3-scale-chromatic@3.0.3': {} @@ -5870,13 +5870,13 @@ snapshots: '@types/docker-modem@3.0.6': dependencies: - '@types/node': 20.14.6 + '@types/node': 20.14.7 '@types/ssh2': 1.15.0 '@types/dockerode@3.3.28': dependencies: '@types/docker-modem': 3.0.6 - '@types/node': 20.14.6 + '@types/node': 20.14.7 '@types/ssh2': 1.15.0 '@types/estree-jsx@1.0.5': @@ -5887,7 +5887,7 @@ snapshots: '@types/express-serve-static-core@4.19.0': dependencies: - '@types/node': 20.14.6 + '@types/node': 20.14.7 '@types/qs': 6.9.15 '@types/range-parser': 1.2.7 '@types/send': 0.17.4 @@ -5901,7 +5901,7 @@ snapshots: '@types/graceful-fs@4.1.9': dependencies: - '@types/node': 20.14.6 + '@types/node': 20.14.7 '@types/hast@2.3.10': dependencies: @@ -5951,7 +5951,7 @@ snapshots: dependencies: undici-types: 5.26.5 - '@types/node@20.14.6': + '@types/node@20.14.7': dependencies: undici-types: 5.26.5 @@ -5969,21 +5969,21 @@ snapshots: '@types/send@0.17.4': dependencies: '@types/mime': 1.3.5 - '@types/node': 20.14.6 + '@types/node': 20.14.7 '@types/serve-static@1.15.7': dependencies: '@types/http-errors': 2.0.4 - '@types/node': 20.14.6 + '@types/node': 20.14.7 '@types/send': 0.17.4 '@types/ssh2-streams@0.1.12': dependencies: - '@types/node': 20.14.6 + '@types/node': 20.14.7 '@types/ssh2@0.5.52': dependencies: - '@types/node': 20.14.6 + '@types/node': 20.14.7 '@types/ssh2-streams': 0.1.12 '@types/ssh2@1.15.0': @@ -6731,13 +6731,13 @@ snapshots: crc-32: 1.2.2 readable-stream: 3.6.2 - create-jest@29.7.0(@types/node@20.14.6): + create-jest@29.7.0(@types/node@20.14.7): dependencies: '@jest/types': 29.6.3 chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@20.14.6) + jest-config: 29.7.0(@types/node@20.14.7) jest-util: 29.7.0 prompts: 2.4.2 transitivePeerDependencies: @@ -8237,7 +8237,7 @@ snapshots: '@jest/expect': 29.7.0 '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.14.6 + '@types/node': 20.14.7 chalk: 4.1.2 co: 4.6.0 dedent: 1.5.3 @@ -8257,16 +8257,16 @@ snapshots: - babel-plugin-macros - supports-color - jest-cli@29.7.0(@types/node@20.14.6): + jest-cli@29.7.0(@types/node@20.14.7): dependencies: '@jest/core': 29.7.0 '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 - create-jest: 29.7.0(@types/node@20.14.6) + create-jest: 29.7.0(@types/node@20.14.7) exit: 0.1.2 import-local: 3.1.0 - jest-config: 29.7.0(@types/node@20.14.6) + jest-config: 29.7.0(@types/node@20.14.7) jest-util: 29.7.0 jest-validate: 29.7.0 yargs: 17.7.2 @@ -8276,7 +8276,7 @@ snapshots: - supports-color - ts-node - jest-config@29.7.0(@types/node@20.14.6): + jest-config@29.7.0(@types/node@20.14.7): dependencies: '@babel/core': 7.24.4 '@jest/test-sequencer': 29.7.0 @@ -8301,7 +8301,7 @@ snapshots: slash: 3.0.0 strip-json-comments: 3.1.1 optionalDependencies: - '@types/node': 20.14.6 + '@types/node': 20.14.7 transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -8330,7 +8330,7 @@ snapshots: '@jest/environment': 29.7.0 '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.14.6 + '@types/node': 20.14.7 jest-mock: 29.7.0 jest-util: 29.7.0 @@ -8340,7 +8340,7 @@ snapshots: dependencies: '@jest/types': 29.6.3 '@types/graceful-fs': 4.1.9 - '@types/node': 20.14.6 + '@types/node': 20.14.7 anymatch: 3.1.3 fb-watchman: 2.0.2 graceful-fs: 4.2.11 @@ -8379,7 +8379,7 @@ snapshots: jest-mock@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 20.14.6 + '@types/node': 20.14.7 jest-util: 29.7.0 jest-pnp-resolver@1.2.3(jest-resolve@29.7.0): @@ -8414,7 +8414,7 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.14.6 + '@types/node': 20.14.7 chalk: 4.1.2 emittery: 0.13.1 graceful-fs: 4.2.11 @@ -8442,7 +8442,7 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.14.6 + '@types/node': 20.14.7 chalk: 4.1.2 cjs-module-lexer: 1.2.3 collect-v8-coverage: 1.0.2 @@ -8488,7 +8488,7 @@ snapshots: jest-util@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 20.14.6 + '@types/node': 20.14.7 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 @@ -8507,7 +8507,7 @@ snapshots: dependencies: '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.14.6 + '@types/node': 20.14.7 ansi-escapes: 4.3.2 chalk: 4.1.2 emittery: 0.13.1 @@ -8516,17 +8516,17 @@ snapshots: jest-worker@29.7.0: dependencies: - '@types/node': 20.14.6 + '@types/node': 20.14.7 jest-util: 29.7.0 merge-stream: 2.0.0 supports-color: 8.1.1 - jest@29.7.0(@types/node@20.14.6): + jest@29.7.0(@types/node@20.14.7): dependencies: '@jest/core': 29.7.0 '@jest/types': 29.6.3 import-local: 3.1.0 - jest-cli: 29.7.0(@types/node@20.14.6) + jest-cli: 29.7.0(@types/node@20.14.7) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -9445,7 +9445,7 @@ snapshots: node-mocks-http@1.14.1: dependencies: '@types/express': 4.17.21 - '@types/node': 20.14.6 + '@types/node': 20.14.7 accepts: 1.3.8 content-disposition: 0.5.4 depd: 1.1.2