console/deployment/utils/service-deployment.ts
Laurin Quast 4f70fc9555
feat: otel telemetry collection and dashboard (#6796)
Co-authored-by: Kamil Kisiela <kamil.kisiela@gmail.com>
Co-authored-by: Dotan Simha <dotansimha@gmail.com>
Co-authored-by: Denis Badurina <denis@denelop.com>
2025-10-10 14:06:02 +02:00

362 lines
10 KiB
TypeScript

import * as k8s from '@pulumi/kubernetes';
import * as kx from '@pulumi/kubernetesx';
import * as pulumi from '@pulumi/pulumi';
import { isDefined } from './helpers';
import { normalizeEnv, PodBuilder } from './pod-builder';
import { ServiceSecret } from './secrets';
type ProbeConfig = Omit<
k8s.types.input.core.v1.Probe,
'httpGet' | 'exec' | 'grpc' | 'tcpSocket'
> & { endpoint: string };
function normalizeEnvSecrets(envSecrets?: Record<string, ServiceSecretBinding<any>>) {
return envSecrets
? Object.keys(envSecrets).map(name => ({
name,
valueFrom: {
secretKeyRef: {
name: envSecrets[name].secret.record.metadata.name,
key: envSecrets[name].key,
},
},
}))
: [];
}
export type ServiceSecretBinding<T extends Record<string, string>> = {
secret: ServiceSecret<T>;
key: keyof T | pulumi.Output<keyof T>;
};
export class ServiceDeployment {
private envSecrets: Record<string, ServiceSecretBinding<any>> = {};
constructor(
protected name: string,
protected options: {
imagePullSecret?: k8s.core.v1.Secret;
env?: kx.types.Container['env'];
args?: kx.types.Container['args'];
image: string;
port?: number;
/** Port to use for liveness, startup and readiness probes. */
probePort?: number;
serviceAccountName?: pulumi.Output<string>;
livenessProbe?: string | ProbeConfig;
readinessProbe?: string | ProbeConfig;
startupProbe?: string | ProbeConfig;
memoryLimit?: string;
volumes?: k8s.types.input.core.v1.Volume[];
volumeMounts?: k8s.types.input.core.v1.VolumeMount[];
/**
* Enables /metrics endpoint on port 10254
*/
exposesMetrics?: boolean;
replicas?: number;
pdb?: boolean;
autoScaling?: {
minReplicas?: number;
maxReplicas: number;
cpu: {
limit: string;
cpuAverageToScale: number;
};
};
availabilityOnEveryNode?: boolean;
command?: pulumi.Input<pulumi.Input<string>[]>;
},
protected dependencies?: Array<pulumi.Resource | undefined | null>,
protected parent?: pulumi.Resource | null,
) {}
withSecret<T extends Record<string, string | pulumi.Output<string>>>(
envVar: string,
secret: ServiceSecret<T>,
key: keyof T,
) {
this.envSecrets[envVar] = { secret, key };
return this;
}
withConditionalSecret<T extends Record<string, string | pulumi.Output<string>>>(
enabled: boolean,
envVar: string,
secret: ServiceSecret<T> | null,
key: keyof T,
) {
if (enabled && secret) {
this.envSecrets[envVar] = { secret, key };
}
return this;
}
deployAsJob() {
const { pb } = this.createPod(true);
const job = new kx.Job(
this.name,
{
spec: pb.asJobSpec(),
},
{ dependsOn: this.dependencies?.filter(isDefined) },
);
return { job };
}
createPod(asJob: boolean) {
const port = this.options.port || 3000;
const probePort = this.options.probePort ?? port;
const additionalEnv: any[] = normalizeEnv(this.options.env);
const secretsEnv: any[] = normalizeEnvSecrets(this.envSecrets);
let startupProbe: k8s.types.input.core.v1.Probe | undefined = undefined;
let livenessProbe: k8s.types.input.core.v1.Probe | undefined = undefined;
let readinessProbe: k8s.types.input.core.v1.Probe | undefined = undefined;
if (this.options.livenessProbe) {
livenessProbe =
typeof this.options.livenessProbe === 'string'
? {
initialDelaySeconds: 10,
terminationGracePeriodSeconds: 60,
periodSeconds: 10,
failureThreshold: 5,
timeoutSeconds: 5,
httpGet: {
path: this.options.livenessProbe,
port: probePort,
},
}
: {
...this.options.livenessProbe,
httpGet: {
path: this.options.livenessProbe.endpoint,
port: probePort,
},
};
}
if (this.options.readinessProbe) {
readinessProbe =
typeof this.options.readinessProbe === 'string'
? {
initialDelaySeconds: 10,
periodSeconds: 15,
failureThreshold: 5,
timeoutSeconds: 5,
httpGet: {
path: this.options.readinessProbe,
port: probePort,
},
}
: {
...this.options.readinessProbe,
httpGet: {
path: this.options.readinessProbe.endpoint,
port: probePort,
},
};
}
if (this.options.startupProbe) {
startupProbe =
typeof this.options.startupProbe === 'string'
? {
initialDelaySeconds: 20,
periodSeconds: 30,
failureThreshold: 10,
timeoutSeconds: 10,
httpGet: {
path: this.options.startupProbe,
port: probePort,
},
}
: {
...this.options.startupProbe,
httpGet: {
path: this.options.startupProbe.endpoint,
port: probePort,
},
};
}
if (this.options.exposesMetrics) {
additionalEnv.push({ name: 'PROMETHEUS_METRICS', value: '1' });
}
const topologySpreadConstraints: k8s.types.input.core.v1.TopologySpreadConstraint[] = [];
if (this.options.availabilityOnEveryNode) {
// This will ensure that services that has >1 replicas will be scheduled on every available node
// and ensure that we are not exposed to downtime issues caused by node failures/restarts:
topologySpreadConstraints.push({
maxSkew: 1,
topologyKey: 'kubernetes.io/hostname',
whenUnsatisfiable: 'ScheduleAnyway',
labelSelector: {
matchLabels: {
app: this.name,
},
},
});
}
const resourcesLimits: Record<string, string> = {};
if (this.options?.autoScaling?.cpu.limit) {
resourcesLimits.cpu = this.options.autoScaling.cpu.limit;
}
if (this.options.memoryLimit) {
resourcesLimits.memory = this.options.memoryLimit;
}
const pb = new PodBuilder({
restartPolicy: asJob ? 'Never' : 'Always',
imagePullSecrets: this.options.imagePullSecret
? [{ name: this.options.imagePullSecret.metadata.name }]
: undefined,
terminationGracePeriodSeconds: 60,
volumes: this.options.volumes,
topologySpreadConstraints,
serviceAccountName: this.options.serviceAccountName,
containers: [
{
livenessProbe,
readinessProbe,
startupProbe,
volumeMounts: this.options.volumeMounts,
imagePullPolicy: 'Always',
env: [
{ name: 'PORT', value: String(port) },
{
name: 'POD_NAME',
valueFrom: {
fieldRef: {
fieldPath: 'metadata.name',
},
},
},
]
.concat(additionalEnv)
.concat(secretsEnv),
name: this.name,
image: this.options.image,
resources: {
limits: resourcesLimits,
},
args: this.options.args,
ports: {
http: port,
...(this.options.exposesMetrics
? {
metrics: 10_254,
}
: {}),
},
command: this.options.command,
},
],
});
return { pb };
}
deploy() {
const { pb } = this.createPod(false);
const metadata: k8s.types.input.meta.v1.ObjectMeta = {
annotations: {},
};
metadata.labels = {
app: this.name,
};
if (this.options.exposesMetrics) {
metadata.annotations = {
'prometheus.io/port': '10254',
'prometheus.io/path': '/metrics',
'prometheus.io/scrape': 'true',
};
}
const deployment = new kx.Deployment(
this.name,
{
spec: pb.asExtendedDeploymentSpec(
{
replicas: this.options.replicas ?? 1,
strategy: {
type: 'RollingUpdate',
rollingUpdate: {
maxSurge: this.options.replicas ?? 1,
maxUnavailable: 0,
},
},
},
{
annotations: metadata.annotations,
labels: metadata.labels,
},
),
},
{
dependsOn: this.dependencies?.filter(isDefined),
parent: this.parent ?? undefined,
},
);
if (this.options.pdb) {
new k8s.policy.v1.PodDisruptionBudget(`${this.name}-pdb`, {
spec: {
minAvailable: 1,
selector: deployment.spec.selector,
},
});
}
const service = deployment.createService({});
if (this.options.autoScaling) {
new k8s.autoscaling.v2.HorizontalPodAutoscaler(
`${this.name}-autoscaler`,
{
apiVersion: 'autoscaling/v2',
kind: 'HorizontalPodAutoscaler',
metadata: {},
spec: {
scaleTargetRef: {
name: deployment.metadata.name,
kind: deployment.kind,
apiVersion: deployment.apiVersion,
},
metrics: [
{
type: 'Resource',
resource: {
name: 'cpu',
target: {
type: 'Utilization',
averageUtilization: this.options.autoScaling.cpu.cpuAverageToScale,
},
},
},
],
minReplicas: this.options.autoScaling.minReplicas || this.options.replicas || 1,
maxReplicas: this.options.autoScaling.maxReplicas,
},
},
{
dependsOn: [deployment, service],
},
);
}
return { deployment, service };
}
}