🌳
pt0/deployF/servicesF/gitsshF/epGitSshAI.mts
1import path from 'path'
31import { spawnSync, execSync } from 'child_process'
33import { tsAbsPath, type absFileDirPath } from "../../../ptDirF.mts"
34import fs from 'fs'
36const importMetaUrl = import.meta.url
37const defaultNodePortNo = 30022
39const getGitsshName = () => {
40 const name = gitsshCtx.getStore()?.name
41 assertDefined(name, {name})
42 return name
44const getGitsshNodePort = () => gitsshCtx.getStore()?.nodePortNo || defaultNodePortNo
45const getRepoPath = () => `/home/git/repos/${gitsshCtx.getStore()?.repoName}.git`
47// Init bare repo on gitssh server (idempotent)
48export const initGitsshRepo = async ({cluster_name, repoName}: {cluster_name: string, repoName?: string}) => {
49 const gitsshName = getGitsshName()
50 const repo = repoName || gitsshCtx.getStore()?.repoName
51 assertDefined(repo, {repo})
52 const repoPath = `/home/git/repos/${repo}.git`
53 const kubeConfigPath = getKubeConfigPath(cluster_name)
55 let podName
56 try {
57 podName = await getMostRecentPod({cluster_name, name: gitsshName})
58 } catch {
59 console.log(`gitssh pod not found, skipping repo init`)
60 return false
61 }
63 // Run as git user (uid 1000) to avoid dubious ownership warnings
64 const initCmd = `git init --bare --initial-branch=main ${repoPath} && git -C ${repoPath} symbolic-ref HEAD refs/heads/main && git -C ${repoPath} config http.receivepack true`
65 const result = spawnSync('kubectl', ['exec', podName, '--', 'su', '-s', '/bin/sh', 'git', '-c', initCmd], {
66 stdio: 'pipe',
67 env: {...process.env, KUBECONFIG: kubeConfigPath},
68 })
70 if (result.status === 0) {
71 const output = result.stdout?.toString() || ''
72 console.log(output.includes('Initialized') ? `Initialized bare repo at ${repoPath}` : `Server repo exists at ${repoPath}`)
73 return true
74 }
75 console.error(`Failed to init repo: ${result.stderr?.toString()}`)
76 return false
79// Generate or load SSH host keys for gitssh (deterministic across deploys)
80const getOrGenHostKeys = (gitsshName: string) => {
81 const keyTypes = ['ed25519', 'rsa', 'ecdsa'] as const
82 const keysH: Record<string, string> = {}
83 for (const keyType of keyTypes) {
84 const privKeyName = `gitssh-${gitsshName}-hostkey-${keyType}`
85 const privKeyPath = pathDownJoin(secretsDir, privKeyName)
86 if (!fs.existsSync(privKeyPath)) {
87 const keygenArgs = keyType === 'rsa' ? '-t rsa -b 4096' : keyType === 'ecdsa' ? '-t ecdsa -b 521' : '-t ed25519'
88 execSync(`ssh-keygen ${keygenArgs} -f "${privKeyPath}" -N "" -C "gitssh-${gitsshName}"`, {stdio: 'pipe'})
89 fs.unlinkSync(privKeyPath + '.pub') // don't need pub key for host keys
90 }
91 keysH[`ssh_host_${keyType}_key`] = fs.readFileSync(privKeyPath, 'utf8')
92 }
93 return keysH
96// Read codeserv public key from secret if it exists
97const getCodeservPubKey = async () => {
98 const pubKeyPath = pathDownJoin(secretsDir, 'codeserv-ssh-key-pub')
99 if (await fileExists(pubKeyPath)) {
100 return (await read1File(pubKeyPath)).trim()
101 }
102 return null
105const modVolsFnc = async ({volumeMounts, volumes, initContainers, resources, annotations}: {volumeMounts: any[], volumes: any[], initContainers: any[], resources: any[], annotations: Record<string, string>}) => {
106 const gitsshCfg = getReqGitsshCtx()
107 const gitsshName = getGitsshName()
108 assertNonEmpty(gitsshCfg.authorizedPubKeyPaths) // must specify pub keys to authorize
110 // Read all configured pub keys
111 const authorizedKeysA: string[] = []
112 for (const keyPath of gitsshCfg.authorizedPubKeyPaths) {
113 const expandedPath = keyPath.replace(/^~/, envHome!)
114 const key = await read1File(tsAbsPath(expandedPath))
115 authorizedKeysA.push(key.trim())
116 }
118 // codeserv container key for internal git clone (read from secret if available)
119 const codeservKey = await getCodeservPubKey()
120 if (codeservKey) authorizedKeysA.push(codeservKey)
121 const authorizedKeys = authorizedKeysA.join('\n')
123 // Get or generate deterministic SSH host keys
124 const hostKeysH = getOrGenHostKeys(gitsshName)
125 annotations.authorizedKeysHash = calcHash(authorizedKeys + JSON.stringify(hostKeysH))
127 volumeMounts.push({
128 name: 'ssh-keys',
129 mountPath: `/ssh-keys`
130 })
131 volumeMounts.push({
132 name: 'git-home',
133 mountPath: '/home/git'
134 })
135 volumeMounts.push({
136 name: 'etc-ssh',
137 mountPath: '/etc/ssh'
138 })
139 volumes.push({
140 name: 'ssh-keys',
141 secret: {
142 secretName: `${gitsshName}-ssh-keys`,
143 defaultMode: 384
144 },
145 })
146 volumes.push({
147 name: 'git-home',
148 emptyDir: {}
149 })
150 volumes.push({
151 name: 'etc-ssh',
152 emptyDir: {}
153 })
155 // Secret includes authorized_keys and host keys
156 const secretData = {
157 'authorized_keys': Buffer.from(authorizedKeys).toString('base64'),
158 ...Object.fromEntries(Object.entries(hostKeysH).map(([k, v]) => [k, Buffer.from(v).toString('base64')]))
159 }
160 resources.push({
161 apiVersion: 'v1',
162 kind: 'Secret',
163 metadata: { name: `${gitsshName}-ssh-keys` },
164 type: 'Opaque',
165 data: secretData
166 })
167 initContainers.push({
168 name: 'setup-ssh',
169 image: 'alpine:latest',
170 command: ['/bin/sh', '-c'],
171 args: [`mkdir -p /home/git/.ssh &&
172cp /ssh-keys/authorized_keys /home/git/.ssh/authorized_keys &&
173cp /ssh-keys/ssh_host_* /etc/ssh/ &&
174chmod 600 /etc/ssh/ssh_host_* &&
175cat > /etc/ssh/sshd_config << 'EOF'
176Port 22
177HostKey /etc/ssh/ssh_host_ed25519_key
178HostKey /etc/ssh/ssh_host_rsa_key
179HostKey /etc/ssh/ssh_host_ecdsa_key
180PermitRootLogin no
181PasswordAuthentication no
182PubkeyAuthentication yes
183AuthorizedKeysFile .ssh/authorized_keys
184Subsystem sftp /usr/lib/ssh/sftp-server
185EOF
186echo '[safe]' > /home/git/.gitconfig &&
187echo ' directory = *' >> /home/git/.gitconfig &&
188echo '[uploadpack]' >> /home/git/.gitconfig &&
189echo ' allowReachableSHA1InWant = true' >> /home/git/.gitconfig &&
190mkdir -p /home/git/git-shell-commands &&
191printf '#!/bin/sh\\ngit -C "/home/git/repos/$1.git" cat-file -e "$2" 2>/dev/null\\n' > /home/git/git-shell-commands/commitexists &&
192chmod +x /home/git/git-shell-commands/commitexists &&
193chown -R 1000:1000 /home/git &&
194chmod 755 /home/git &&
195chmod 700 /home/git/.ssh &&
196 chmod 600 /home/git/.ssh/authorized_keys`], // alpine doesnt have git fyi
197 volumeMounts
198 })
200 // HTTPS (Smart HTTP) git: nginx + git-http-backend, htpasswd (bcrypt) auth derived from a token secret
201 const httpToken = await genPlainSecretIfMissing({secretName: `${gitsshName}-httptoken`, autoYes: true})
202 volumeMounts.push({name: 'http-shared', mountPath: '/shared'})
203 volumes.push({name: 'http-shared', emptyDir: {}})
204 volumes.push({name: 'http-token', secret: {secretName: `${gitsshName}-httptoken`}})
205 resources.push({apiVersion: 'v1', kind: 'Secret', type: 'Opaque', metadata: {name: `${gitsshName}-httptoken`}, stringData: {token: httpToken}})
206 initContainers.push({
207 name: 'setup-http',
208 image: 'httpd:alpine',
209 command: ['/bin/sh', '-c'],
210 args: ['htpasswd -bnB git "$(cat /token/token)" > /shared/htpasswd && chmod 644 /shared/htpasswd'],
211 volumeMounts: [{name: 'http-shared', mountPath: '/shared'}, {name: 'http-token', mountPath: '/token', readOnly: true}],
212 })
215export const k8sGitSsh = async () => {
216 ptKubeCtx.enterWith({...ptKubeCtx.getStore(), modVolsFnc})
217 const appCfg = getAppCfg()
218 const {cluster_name} = appCfg
219 const action = getAction()
220 assertDefined(cluster_name, {cluster_name})
222 // Register setup, setorigin and push actions for help display
223 const setup = async () => setupGitSshLocal({cluster_name})
224 setup.cliDescript = 'configure local ssh/git and init server repo (idempotent)'
225 const setoriginArgs = () => { const a = getProcArgv().slice(3).filter(x => !x.startsWith('-')); return {repoName: a[0] as string | undefined, localRepoPath: a[1] as string | undefined} }
226 const setorigin = async () => { const {repoName, localRepoPath} = setoriginArgs(); return setoriginGitSshLocal(repoName, localRepoPath) }
227 setorigin.cliDescript = 'set origin remote to gitssh URL (idempotent): setorigin <repoName> [localPath]'
228 const pushArgs = () => { const a = getProcArgv().slice(3).filter(x => !x.startsWith('-')); return {localRepoPath: a[0], repoName: a[1] || gitsshCtx.getStore()?.repoName} }
229 const push = async () => pushToGitsshHttps(pushArgs())
230 push.cliDescript = 'push a local repo to gitssh over HTTPS: push <localPath> [<repoName>]'
231 availActionsCtx.enterWith({...availActionsCtx.getStore(), setup, setorigin, push})
233 // Handle setup/setorigin/push actions before eptSimpleDfDeploy (which would reject unknown actions)
234 if (action === 'setup') {
235 await setupGitSshLocal({cluster_name})
236 return
237 }
238 if (action === 'setorigin') {
239 const {repoName, localRepoPath} = setoriginArgs()
240 await setoriginGitSshLocal(repoName, localRepoPath)
241 return
242 }
243 if (action === 'push') {
244 await pushToGitsshHttps(pushArgs())
245 return
246 }
248 const gitsshName = getGitsshName()
249 const gitsshSizeGb = gitsshCtx.getStore()?.sizeGb || 10
250 const cfgStorClassName = gitsshCtx.getStore()?.pvcStorClassName
251 let pvcStorClassName: string | undefined
252 if (cfgStorClassName && action === 'apply') {
253 pvcStorClassName = await getOptStorClassName({cluster_name, name: cfgStorClassName})
254 if (!pvcStorClassName) console.log(`Warning: storage class ${cfgStorClassName} not found, falling back to default`)
255 }
256 nextContCtx.enterWith({...nextContCtx.getStore(), ports: [{containerPort: 22}, {containerPort: 80}]})
258 ...appCfg,
259 // https://semanticdiff.com/online-diff/json/
260 mountPath: '/home/git/repos', sizeGb: gitsshSizeGb,
261 nodePortNo: getGitsshNodePort(),
262 svcPortNo: 22,
263 importMetaUrl,
264 name: gitsshName,
265 ...(pvcStorClassName && {pvcStorClassName}),
266 })
268 const httpsHostname = gitsshCtx.getStore()?.gitsshHttpsHostname
269 if (httpsHostname) {
270 await resourcesAction({resources: [
271 kubeSvcTmpl({name: `${gitsshName}-http`, portNo: 80, svcName: gitsshName}),
272 genericIngressTmpl({name: `${gitsshName}-http`, svcName: `${gitsshName}-http`, hostname: httpsHostname, portNo: 80}),
273 ], action, cluster_name})
274 if (action === 'apply') {
275 const httpToken = await genPlainSecretIfMissing({secretName: `${gitsshName}-httptoken`, autoYes: true})
276 console.log(`\ngitssh HTTPS ready: https://git:${httpToken}@${httpsHostname}/git/${gitsshCtx.getStore()?.repoName}.git`)
277 }
278 }
280 if (action === 'apply') {
281 const initRepos = gitsshCtx.getStore()?.initRepos ?? [gitsshCtx.getStore()?.repoName]
282 for (const repo of initRepos) {
283 if (!repo) continue
284 await initGitsshRepo({cluster_name, repoName: repo})
285 }
286 }
289const sshConfigPath = `${envHome}/.ssh/config`
291const getSshConfigBlock = ({sshHostAlias, hostname, sshKeyPath, nodePortNo}: {sshHostAlias: string, hostname: string, sshKeyPath: string, nodePortNo: number}) => `
292Host ${sshHostAlias}
293 HostName ${hostname}
294 Port ${nodePortNo}
295 User git
296 IdentityFile ${sshKeyPath}
297`.trim()
299const getHostBlockRegex = (sshHostAlias: string) => new RegExp(`Host ${sshHostAlias}\\n(?: [^\\n]+\\n)+`)
301export const setoriginGitSshLocal = async (repoName?: string, localRepoPath?: string) => {
302 const gitsshCfg = getReqGitsshCtx()
303 const repo = repoName || gitsshCfg.repoName
304 assertDefined(repo, {repo})
305 const execOpts = localRepoPath ? {cwd: localRepoPath} : undefined
306 let gitRemoteUrl: string, displayUrl: string
307 if (gitsshCfg.gitsshHttpsHostname) {
308 const token = await genPlainSecretIfMissing({secretName: `${getGitsshName()}-httptoken`, autoYes: true})
309 gitRemoteUrl = `https://git:${token}@${gitsshCfg.gitsshHttpsHostname}/git/${repo}.git`
310 displayUrl = `https://git:***@${gitsshCfg.gitsshHttpsHostname}/git/${repo}.git`
311 } else {
312 assertDefined(gitsshCfg.sshHostAlias)
313 gitRemoteUrl = `ssh://${gitsshCfg.sshHostAlias}/home/git/repos/${repo}.git`
314 displayUrl = gitRemoteUrl
315 }
317 let existingUrl = null
318 try {
319 const { stdout } = await doExec(`git remote get-url origin`, execOpts)
320 existingUrl = String(stdout).trim()
321 } catch { // catch:userapproved
322 // origin doesn't exist
323 }
325 if (existingUrl === gitRemoteUrl) {
326 console.log(`origin already set to ${displayUrl}, skipping`)
327 } else if (existingUrl) {
328 await doExec(`git remote set-url origin ${gitRemoteUrl}`, execOpts)
329 console.log(`Updated origin -> ${displayUrl}`)
330 } else {
331 await doExec(`git remote add origin ${gitRemoteUrl}`, execOpts)
332 console.log(`Added origin: ${displayUrl}`)
333 }
336export const setupGitSshLocal = async ({cluster_name}: {cluster_name: string}) => {
337 const gitsshCfg = getReqGitsshCtx()
338 const {repoName, sshHostAlias, sshKeyPath} = gitsshCfg
339 assertDefined(repoName)
340 assertDefined(sshHostAlias)
341 assertDefined(sshKeyPath)
342 const gitsshName = getGitsshName()
343 const nodePortNo = getGitsshNodePort()
344 const repoPath = `/home/git/repos/${repoName}.git`
345 const gitRemoteUrl = `ssh://${sshHostAlias}${repoPath}`
347 const klustCfg = getReqKlusterCtx()
348 const { nodeIpsExtHost, lanNodeIp, clusterVip } = klustCfg
350 const onLan = await isOnSameLan({cluster_name, nodeIpsExtHost, clusterVip})
351 const sshHostname = onLan ? lanNodeIp : nodeIpsExtHost
352 console.log(`Detected network: ${onLan ? 'LAN' : 'external'}, using ${sshHostname}`)
354 // 1. Setup ~/.ssh/config
355 let sshConfig = ''
356 try {
357 sshConfig = await read1File(tsAbsPath(sshConfigPath))
358 } catch { // catch:userapproved
359 // file doesn't exist, will create
360 }
362 const sshConfigBlock = getSshConfigBlock({sshHostAlias, hostname: sshHostname!, sshKeyPath, nodePortNo})
363 const hostBlockRegex = getHostBlockRegex(sshHostAlias)
364 const hasHostBlock = hostBlockRegex.test(sshConfig)
366 if (hasHostBlock) {
367 // Check if existing config has the right hostname AND port (within this specific host block)
368 const existingBlock = sshConfig.match(hostBlockRegex)?.[0] || ''
369 const hasCorrectHost = existingBlock.includes(`HostName ${sshHostname}`)
370 const hasCorrectPort = existingBlock.includes(`Port ${nodePortNo}`)
371 if (hasCorrectHost && hasCorrectPort) {
372 console.log(`~/.ssh/config already has Host ${sshHostAlias} with correct hostname, skipping`)
373 } else {
374 // Replace existing block with new one
375 const newConfig = sshConfig.replace(hostBlockRegex, sshConfigBlock + '\n')
376 await fs1Promises.writeFile(sshConfigPath, newConfig)
377 console.log(`Updated Host ${sshHostAlias} in ~/.ssh/config with hostname ${sshHostname} port ${nodePortNo}`)
378 }
379 } else {
380 const newConfig = sshConfig ? `${sshConfig.trimEnd()}\n\n${sshConfigBlock}\n` : `${sshConfigBlock}\n`
381 await fs1Promises.writeFile(sshConfigPath, newConfig)
382 console.log(`Added Host ${sshHostAlias} to ~/.ssh/config`)
383 }
385 // 2. Setup git remote (use cluster-specific name to avoid conflicts with 'origin')
386 const gitRemoteName = `${cluster_name}git`
387 let existingUrl = null
388 try {
389 const { stdout } = await doExec(`git remote get-url ${gitRemoteName}`)
390 existingUrl = stdout.trim()
391 } catch { // catch:userapproved
392 // remote doesn't exist
393 }
395 if (existingUrl === gitRemoteUrl) {
396 console.log(`git remote ${gitRemoteName} already set to ${gitRemoteUrl}, skipping`)
397 } else if (existingUrl) {
398 await doExec(`git remote set-url ${gitRemoteName} ${gitRemoteUrl}`)
399 console.log(`Updated git remote ${gitRemoteName}: ${existingUrl} -> ${gitRemoteUrl}`)
400 } else {
401 await doExec(`git remote add ${gitRemoteName} ${gitRemoteUrl}`)
402 console.log(`Added git remote ${gitRemoteName}: ${gitRemoteUrl}`)
403 }
405 // 3. Init bare repo on server (idempotent - git init --bare is safe to run multiple times)
406 console.log(`Initializing repo on server...`)
407 const initOk = await initGitsshRepo({cluster_name})
408 if (!initOk) return
410 // 4. Update known_hosts with current host key (deterministic keys survive pod recreation, but stale entries may linger)
411 const hostEntry = `[${sshHostname}]:${nodePortNo}`
412 try { execSync(`ssh-keygen -R '${hostEntry}' 2>/dev/null`, {stdio: 'pipe'}) } catch {} // catch:userapproved
413 execSync(`ssh-keyscan -p ${nodePortNo} ${sshHostname} >> ~/.ssh/known_hosts 2>/dev/null`, {stdio: 'pipe'})
415 console.log(`\nSetup complete. You can now run 'git push ${gitRemoteName} main' to push.`)
418export const pushToGitsshHttps = async ({localRepoPath, repoName}: {localRepoPath?: string, repoName?: string}) => {
419 assertDefined(localRepoPath, {localRepoPath})
420 assertDefined(repoName, {repoName})
421 const gitsshName = getGitsshName()
422 const httpsHostname = gitsshCtx.getStore()?.gitsshHttpsHostname
423 assertDefined(httpsHostname, {httpsHostname})
424 const token = await genPlainSecretIfMissing({secretName: `${gitsshName}-httptoken`, autoYes: true})
425 const remoteUrl = `https://git:${token}@${httpsHostname}/git/${repoName}.git`
426 const gitSize = fmtSizeMb(getDirSize(path.join(localRepoPath, '.git')))
427 console.log(`Pushing ${shortPtPath(localRepoPath)} (${gitSize}) to ${httpsHostname}/git/${repoName}.git...`)
428 await liveSpawnThrow({cmd: `/usr/bin/git push --force --all --progress ${remoteUrl}`, cwd: localRepoPath, noOutCmd: true})
429 console.log(`Pushed ${repoName} to gitssh`)
432export const pushLocalRepoToGitssh = async ({localRepoPath, cluster_name}: {localRepoPath: string, cluster_name: string}) => {
433 const {sshHostAlias, repoName} = getReqGitsshCtx()
434 const remoteUrl = `ssh://${sshHostAlias}/home/git/repos/${repoName}.git`
435 const gitRemoteName = `${cluster_name}git`
437 const {stdout: existingUrl} = await doExec(`git remote get-url ${gitRemoteName}`, {cwd: localRepoPath}).catch(() => ({stdout: ''}))
438 if (existingUrl.trim() !== remoteUrl) {
439 await doExec(`git remote remove ${gitRemoteName}`, {cwd: localRepoPath}).catch(() => {})
440 await doExec(`git remote add ${gitRemoteName} ${remoteUrl}`, {cwd: localRepoPath})
441 }
442 const gitDir = path.join(localRepoPath, '.git')
443 const gitSize = fmtSizeMb(getDirSize(gitDir))
444 console.log(`Pushing ${shortPtPath(localRepoPath)} (${gitSize}) to ${remoteUrl}...`)
445 await liveSpawnThrow({cmd: `/usr/bin/git push ${gitRemoteName} --all --force --progress`, cwd: localRepoPath, noOutCmd: true})
446 console.log(`Pushed to gitssh`)