Skip to content

Commit

Permalink
feat(cd8ks): volume expansion support (#126)
Browse files Browse the repository at this point in the history
  • Loading branch information
fuxingloh authored Jun 21, 2024
1 parent 392ae5a commit 7f60eb8
Show file tree
Hide file tree
Showing 7 changed files with 338 additions and 69 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ exports[`bitcoin_mainnet.json should synth bitcoin_mainnet.json and match snapsh
],
"resources": {
"requests": {
"storage": "600Gi",
"storage": "737280Mi",
},
},
},
Expand Down
6 changes: 6 additions & 0 deletions packages/chainfile-cdk8s/src/chart.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
34 changes: 9 additions & 25 deletions packages/chainfile-cdk8s/src/sts.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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 }),
Expand All @@ -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] });
},
});
}),
Expand All @@ -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,
}),
};
}),
},
Expand Down
185 changes: 185 additions & 0 deletions packages/chainfile-cdk8s/src/volume.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
});
94 changes: 94 additions & 0 deletions packages/chainfile-cdk8s/src/volume.ts
Original file line number Diff line number Diff line change
@@ -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}`);
}
}
Loading

0 comments on commit 7f60eb8

Please sign in to comment.