You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
229 lines
8.2 KiB
229 lines
8.2 KiB
import {
|
|
Singleton,
|
|
Inject,
|
|
ErrorWithContext,
|
|
objectToKeyValue,
|
|
fetch,
|
|
Container,
|
|
Logging,
|
|
universalPath,
|
|
} from '@extollo/lib'
|
|
import { Node } from "../models/Node.model";
|
|
import {Setting} from '../models/Setting.model'
|
|
import {IpRange} from '../models/IpRange.model'
|
|
import { Proxmox } from 'proxmox-api'
|
|
import {Images, LXCImage} from './Images.service'
|
|
import {unsafeESMImport} from '@extollo/lib/lib/util/unsafe'
|
|
import * as sshpk from 'sshpk'
|
|
import {shellCommand, SSHHost} from '../support/hosts'
|
|
|
|
@Singleton()
|
|
export class Provisioner {
|
|
@Inject()
|
|
protected readonly images!: Images
|
|
|
|
@Inject()
|
|
protected readonly container!: Container
|
|
|
|
@Inject()
|
|
protected readonly logging!: Logging
|
|
|
|
private cachedProxmoxApi?: Proxmox.Api
|
|
|
|
public async provisionMasterNode(): Promise<Node> {
|
|
// Look up the name of the master node from the settings
|
|
const pveMasterNode = await Setting.loadOneRequired('pveMasterNode')
|
|
|
|
// Look up the P5x storage pool from the settings
|
|
const pveStoragePool = await Setting.loadOneRequired('pveStoragePool')
|
|
|
|
// Look up the network bridge to which we will join the master node
|
|
const nodeNetworkBridge = await Setting.loadOneRequired('nodeNetworkBridge')
|
|
|
|
// Look up the primary IP pool range
|
|
const ipRange = await IpRange.getDefault()
|
|
|
|
// Look up the DNS search domain
|
|
const dnsDomain = await Setting.loadOneRequired('dnsDomain')
|
|
|
|
// Look up the root password for LXC containers
|
|
const rootPassword = await Setting.loadOneRequired('rootPassword')
|
|
|
|
// Get the Proxmox API
|
|
const proxmox = await this.getApi()
|
|
|
|
// Grab the master Proxmox node from the API
|
|
const masterNode = proxmox.nodes.$(pveMasterNode)
|
|
|
|
// Create a storage mount for the LXC template image
|
|
this.logging.info('Creating storage for container templates...')
|
|
const imageCacheStorage = await proxmox.storage.$post({
|
|
storage: 'p5x.image-cache',
|
|
type: 'dir',
|
|
content: ['vztmpl', 'iso'].join(','),
|
|
path: '/p5x-images',
|
|
})
|
|
const masterNodeStorage = masterNode.storage.$(imageCacheStorage.storage)
|
|
|
|
// Download the container template image
|
|
this.logging.info('Looking up LXC image for control node...')
|
|
const image = await this.images.getImages()
|
|
.then(x =>
|
|
x.where('os', '=', 'ubuntu')
|
|
.where('version', '=', 'jammy') // 22.04 LTS
|
|
.where('format', '=', 'cloud')
|
|
.where('arch', '=', 'amd64')
|
|
.sortByDesc('timestamp')
|
|
.first()
|
|
)
|
|
|
|
if ( !image ) {
|
|
throw new ErrorWithContext('Unable to find Debian Bookworm LXC template binary')
|
|
}
|
|
|
|
const imageUrl = this.images.getImageBinaryPath(image!)
|
|
|
|
// Upload the template image to the storage mount
|
|
const uploadTaskUPID = await masterNodeStorage['download-url'].$post({
|
|
content: 'vztmpl',
|
|
filename: 'p5x-base.tar.xz',
|
|
url: imageUrl.toRemote,
|
|
})
|
|
|
|
this.logging.info('Waiting for LXC image to upload to master PVE node...')
|
|
await this.waitForNodeTask(pveMasterNode, uploadTaskUPID)
|
|
|
|
// Get the next vmid to be used by the LXC container
|
|
const masterVMID = await proxmox.cluster.nextid.$get()
|
|
const masterIP = await ipRange.getNextAvailableOrFail()
|
|
|
|
// Create a new LXC container on that node for the control server
|
|
const sshPubKey = sshpk.parseKey(await Setting.loadOneRequired('sshPublicKey'))
|
|
.toString('ssh')
|
|
|
|
const masterTaskUPID = await masterNode.lxc.$post({
|
|
ostemplate: 'p5x.image-cache:vztmpl/p5x-base.tar.xz',
|
|
vmid: masterVMID,
|
|
cores: 1,
|
|
description: 'P5x Control Node',
|
|
hostname: 'p5x-control',
|
|
memory: 512, // in MB
|
|
net0:
|
|
objectToKeyValue({
|
|
name: 'eth0',
|
|
bridge: nodeNetworkBridge,
|
|
firewall: 1,
|
|
gw: ipRange.gatewayIp,
|
|
ip: `${masterIP}/${ipRange.subnet}`,
|
|
})
|
|
.map(x => `${x.key}=${x.value}`)
|
|
.join(','),
|
|
onboot: true,
|
|
password: rootPassword,
|
|
rootfs: `${pveStoragePool}:8`, // 8 GiB
|
|
searchdomain: dnsDomain,
|
|
'ssh-public-keys': sshPubKey,
|
|
start: true,
|
|
storage: pveStoragePool,
|
|
tags: 'p5x',
|
|
})
|
|
|
|
this.logging.info('Waiting for PVE master node to be created...')
|
|
await this.waitForNodeTask(pveMasterNode, masterTaskUPID)
|
|
|
|
// Save the node
|
|
const node = this.container.makeNew<Node>(Node)
|
|
node.pveId = masterVMID
|
|
node.hostname = 'p5x-control'
|
|
node.assignedIp = masterIP
|
|
node.assignedSubnet = ipRange.subnet
|
|
node.isPermanent = true
|
|
node.isMaster = true
|
|
await node.save()
|
|
|
|
// Wait for the host to come online (this will be a PCTHost passed through PVE)
|
|
this.logging.info('Waiting for master node to come online...')
|
|
const proxyHost = await node.getHost()
|
|
await proxyHost.waitForAlive()
|
|
|
|
// Configure SSH on the instance
|
|
this.logging.info('Configuring SSH access to master node...')
|
|
await proxyHost.run(shellCommand('apt update'))
|
|
await proxyHost.run(shellCommand('apt install -y openssh-server curl'))
|
|
await proxyHost.run(shellCommand('systemctl enable --now ssh'))
|
|
await new Promise<void>(res => setTimeout(() => res(), 5000))
|
|
|
|
// Try to re-connect to the instance directly
|
|
this.logging.info('Attempting to re-connect to master node via SSH...')
|
|
const sshHost = await node.getHost()
|
|
if ( !(sshHost instanceof SSHHost) ) {
|
|
throw new ErrorWithContext('Failed to establish direct connection to master node', {
|
|
node,
|
|
proxyHost,
|
|
sshHost,
|
|
})
|
|
}
|
|
|
|
// Install base environment needed to run this control server
|
|
await sshHost.run(shellCommand('curl -fsSL https://deb.nodesource.com/setup_20.x | bash -'))
|
|
await sshHost.run(shellCommand('apt install git nodejs -y'))
|
|
|
|
return node
|
|
}
|
|
|
|
protected async waitForNodeTask(nodeName: string, taskUPID: string): Promise<void> {
|
|
const proxmox = await this.getApi()
|
|
const proxmoxNode = proxmox.nodes.$(nodeName)
|
|
|
|
while ( true ) {
|
|
const status = await proxmoxNode.tasks.$(taskUPID).status.$get()
|
|
if ( status.status === 'running' ) {
|
|
await new Promise(res => setTimeout(res, 1000))
|
|
continue
|
|
}
|
|
|
|
if ( status.exitstatus !== 'OK' ) {
|
|
throw new ErrorWithContext('PVE task did not succeed!', {
|
|
nodeName,
|
|
taskUPID,
|
|
status,
|
|
})
|
|
}
|
|
|
|
break
|
|
}
|
|
}
|
|
|
|
public async getApi(): Promise<Proxmox.Api> {
|
|
// Have to force this to be a runtime ESM import since ts-node
|
|
// is re-writing it to a require(...), breaking the package.
|
|
const proxmoxApi = await unsafeESMImport('proxmox-api').then(x => x.proxmoxApi)
|
|
|
|
// If we have a cached version, just return that
|
|
if ( this.cachedProxmoxApi ) {
|
|
return this.cachedProxmoxApi
|
|
}
|
|
|
|
// Look up the master Proxmox node from the settings
|
|
const pveHost = await Setting.loadOneRequired('pveApiHost')
|
|
|
|
// Look up the Proxmox credentials from the settings
|
|
// const pveTokenId = await Setting.loadOneRequired('pveTokenId')
|
|
// const pveTokenSecret = await Setting.loadOneRequired('pveTokenSecret')
|
|
const pveRootPassword = await Setting.loadOneRequired('pveRootPassword')
|
|
|
|
// Initialize the Proxmox API
|
|
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
|
this.cachedProxmoxApi = proxmoxApi({
|
|
host: pveHost,
|
|
user: 'root@pam',
|
|
password: pveRootPassword,
|
|
// tokenID: pveTokenId,
|
|
// tokenSecret: pveTokenSecret,
|
|
}) as Proxmox.Api
|
|
|
|
return this.cachedProxmoxApi
|
|
}
|
|
|
|
}
|