🌳
pt0/deployF/k8sF/kubeClientF.mts
1import * as _ from 'lodash-es'
7import { getKlusterCtx, type KubeResource } from './ctxF/klusterCtxF.mts'
8import { getKubeApis } from './getApisF.mts'
19type WithResourceAndCluster = { resource: KubeResource, cluster_name: string }
21const getApisForResource = async ({resource, cluster_name}: WithResourceAndCluster) => {
22 assertDefined(cluster_name)
23 const offlineReason = getOfflineReason(cluster_name)
24 if (offlineReason) throw new PtErr(`cluster ${cluster_name} is offline`, {cluster_name, offlineReason, resource: inspect2KubeRes({resource, cluster_name})})
25 return await getKubeApis({cluster_name})
28type ObjectApiMethodCatchProps = WithResourceAndCluster & { method_name: string }
29export const objectApiMethodCatch = async ({method_name, resource, cluster_name}: ObjectApiMethodCatchProps) => {
30 const {objectApi} = await getApisForResource({resource, cluster_name})
32 if (method_name == 'create' || method_name == 'patch') {
33 const contents = JSON.stringify(resource, null, 2)
34 const tmpResPath = pathDownJoin(await ensureTmpDirExists('kubetmp'), calcHash(contents) + '.json')
35 await write1File(tmpResPath, contents)
36 // TODO cleanup: server-side apply avoids the 256KB annotation size limit when shipping
37 // large ConfigMaps (e.g. worker build artifacts via inclContentH). Could scope this to
38 // only large ConfigMaps instead of applying globally, but server-side apply is the
39 // modern k8s standard anyway.
40 const cmdA = [cluster_name, 'apply', '--server-side', '--validate=false', '--force-conflicts', '-f', tmpResPath]
41 let result: {isSuccess?: boolean, stderr?: string, code?: number} | undefined
42 await noOutCmdCtx.run({noOutput: true} as any, async () => {
43 try {
44 result = await withKubeRetry(async () => {
45 const r = await eptKubeCli(cmdA) as typeof result
46 if (r && !r.isSuccess) throw new Error(r.stderr || `kubectl code=${r.code}`)
47 return r
48 }, cluster_name) as typeof result
49 } catch (err: any) {
50 throw new PtErr('kubectl apply failed', {resource: inspect2KubeRes({resource, cluster_name}), stderr: String(err?.message || err)})
51 }
52 })
53 if (result && !result.isSuccess) {
54 throw new PtErr('kubectl apply failed', {resource: inspect2KubeRes({resource, cluster_name}), stderr: result.stderr})
55 }
56 return resource
57 }
58 // ^properly throws when invalid backup: param of zalando specified. unlike this code:
59 betVerboseLog({method_name}, resource)
61 if (method_name == 'recreate') {
62 await res1Action({resource, action: 'delete', cluster_name})
63 method_name = 'create'
64 }
66 try {
67 return await withKubeRetry(() => (objectApi as any)[method_name](resource), cluster_name)
68 } catch(err: any) {
70 if (!err.body) throw err
71 const bodyH = json1ParseCatch(err.body)
72 if (!bodyH) throw err
74 const {reason} = bodyH as { reason?: string }
75 if (reason == 'AlreadyExists') {
76 return resource
77 }
79 throw new PtErr('!doObjectApiMethodCatch', {
80 method_name,
81 bodyH, resource: inspect2KubeRes({resource, cluster_name})
82 })
83 }
86const cantPatch = ({resource}: {resource: KubeResource}) => {
87 const {kind} = resource
89 const type = _.get(resource, 'spec.type')
91 const isNodePort = kind == 'Service' && type == 'NodePort'
93 return isNodePort || _.includes([
94 'Certificate',
95 'StorageClass',
96 'PersistentVolumeClaim',
97 'Job'
98 ], kind)
101export const applyResource = async ({resource, cluster_name}: WithResourceAndCluster) => {
102 const existing = await read2Resource({resource, cluster_name})
104 let action = 'create'
105 if (existing) {
106 if (cantPatch({resource})) {
107 action = 'cantupdate'
108 } else if (_.includes(['letsencrypt-prod', 'le-wildcard-issuer'], _.get(resource, 'metadata.name'))) {
109 action = 'recreate'
110 } else if (resource.kind === 'Deployment' && _.get(resource, 'spec.strategy.type') === 'Recreate' && _.get(existing, 'spec.strategy.type') !== 'Recreate') {
111 action = 'recreate'
112 } else {
113 action = 'patch'
114 }
115 }
117 const {name} = resource.metadata, {kind} = resource
118 if (action == 'cantupdate') {
119 if (kind == 'Certificate') {
120 const existingDns = _.get(existing, 'spec.dnsNames') as string[] | undefined, desiredDns = _.get(resource, 'spec.dnsNames') as string[] | undefined
121 if (!_.isEqual(_.sortBy(existingDns ?? []), _.sortBy(desiredDns ?? []))) {
122 betLog(`can't patch Certificate ${name}; delete + re-apply to change SANs`, {existingDns, desiredDns})
123 }
124 }
125 return {action, cluster_name, name, kind}
126 }
128 await objectApiMethodCatch({method_name: action, resource, cluster_name})
129 return {action, cluster_name, name, kind}
132type ObjectApiCallProps = WithResourceAndCluster & { objectApiMethod: string }
133export const objectApiCall = async ({resource, objectApiMethod, cluster_name}: ObjectApiCallProps) => {
134 const {objectApi} = await getApisForResource({resource, cluster_name})
135 let body
136 try {
137 ;({body} = await (objectApi as any)[objectApiMethod](resource))
138 } catch (err: any) {
139 if (!err.body) throw err
140 const {reason} = json1Parse(err.body) as { reason?: string }
141 if (reason == 'AlreadyExists') {
142 return resource
143 }
144 }
145 return body
148export const inspect2KubeRes = ({resource, cluster_name}: {resource: KubeResource, cluster_name?: string}) => {
149 cluster_name ||= getKlusterCtx().cluster_name
150 return cluster_name + ' ' + inspect3KubeRes(resource)
153export const delete2Resource = async ({resource, cluster_name}: WithResourceAndCluster) => {
154 const resp = await objectApiCall({resource, objectApiMethod: 'delete', cluster_name}) as { code?: number, status?: string } | undefined
155 const {name} = resource.metadata, {kind} = resource
156 const action = (() => {
157 if (!resp) return 'deleted'
158 const {code, status} = resp
159 if (status == 'Success') return 'deleted'
160 if (code == 404) return 'notexist'
161 return 'deleted'
162 })()
163 return {action, cluster_name, name, kind}