feat(core): Initial project scaffold and implementation: Deno CLI, ISO tooling, cloud-init generation, packaging and installer scripts
This commit is contained in:
168
ts/classes/cloud-init-generator.ts
Normal file
168
ts/classes/cloud-init-generator.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Cloud-Init Generator
|
||||
* Generates cloud-init configuration files
|
||||
*/
|
||||
|
||||
import { yaml, path as pathUtil } from '../plugins.ts';
|
||||
import { log } from '../logging.ts';
|
||||
import type { ICloudInitConfig } from '../interfaces/iso-config.interface.ts';
|
||||
|
||||
export interface INetworkConfig {
|
||||
wifi?: {
|
||||
ssid: string;
|
||||
password: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class CloudInitGenerator {
|
||||
/**
|
||||
* Generate user-data file content
|
||||
*/
|
||||
generateUserData(config: ICloudInitConfig): string {
|
||||
const userDataObj: Record<string, unknown> = {
|
||||
'#cloud-config': null,
|
||||
};
|
||||
|
||||
// Hostname
|
||||
if (config.hostname) {
|
||||
userDataObj.hostname = config.hostname;
|
||||
}
|
||||
|
||||
// Users
|
||||
if (config.users && config.users.length > 0) {
|
||||
userDataObj.users = config.users.map((user) => ({
|
||||
name: user.name,
|
||||
...(user.ssh_authorized_keys && { ssh_authorized_keys: user.ssh_authorized_keys }),
|
||||
...(user.sudo && { sudo: user.sudo }),
|
||||
...(user.shell && { shell: user.shell }),
|
||||
...(user.groups && { groups: user.groups }),
|
||||
...(user.lock_passwd !== undefined && { lock_passwd: user.lock_passwd }),
|
||||
...(user.passwd && { passwd: user.passwd }),
|
||||
}));
|
||||
}
|
||||
|
||||
// Packages
|
||||
if (config.packages && config.packages.length > 0) {
|
||||
userDataObj.packages = config.packages;
|
||||
}
|
||||
|
||||
// Package update/upgrade
|
||||
if (config.package_update !== undefined) {
|
||||
userDataObj.package_update = config.package_update;
|
||||
}
|
||||
if (config.package_upgrade !== undefined) {
|
||||
userDataObj.package_upgrade = config.package_upgrade;
|
||||
}
|
||||
|
||||
// Run commands
|
||||
if (config.runcmd && config.runcmd.length > 0) {
|
||||
userDataObj.runcmd = config.runcmd;
|
||||
}
|
||||
|
||||
// Write files
|
||||
if (config.write_files && config.write_files.length > 0) {
|
||||
userDataObj.write_files = config.write_files;
|
||||
}
|
||||
|
||||
// Timezone
|
||||
if (config.timezone) {
|
||||
userDataObj.timezone = config.timezone;
|
||||
}
|
||||
|
||||
// Locale
|
||||
if (config.locale) {
|
||||
userDataObj.locale = config.locale;
|
||||
}
|
||||
|
||||
// SSH settings
|
||||
if (config.ssh) {
|
||||
userDataObj.ssh = config.ssh;
|
||||
}
|
||||
|
||||
// Add any additional custom directives
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
if (!['hostname', 'users', 'packages', 'package_update', 'package_upgrade',
|
||||
'runcmd', 'write_files', 'timezone', 'locale', 'ssh'].includes(key)) {
|
||||
userDataObj[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the cloud-config comment from the object before YAML conversion
|
||||
delete userDataObj['#cloud-config'];
|
||||
|
||||
// Convert to YAML and add the cloud-config header
|
||||
const yamlContent = yaml.stringify(userDataObj);
|
||||
return `#cloud-config\n${yamlContent}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate network-config file content (for WiFi)
|
||||
*/
|
||||
generateNetworkConfig(networkConfig: INetworkConfig): string {
|
||||
if (!networkConfig.wifi) {
|
||||
return yaml.stringify({ version: 2 });
|
||||
}
|
||||
|
||||
const { ssid, password } = networkConfig.wifi;
|
||||
|
||||
const netConfig = {
|
||||
version: 2,
|
||||
wifis: {
|
||||
wlan0: {
|
||||
dhcp4: true,
|
||||
optional: true,
|
||||
'access-points': {
|
||||
[ssid]: {
|
||||
password: password,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return yaml.stringify(netConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate meta-data file content
|
||||
*/
|
||||
generateMetaData(hostname?: string): string {
|
||||
const metaData = {
|
||||
'instance-id': `iid-${Date.now()}`,
|
||||
'local-hostname': hostname || 'ubuntu',
|
||||
};
|
||||
|
||||
return yaml.stringify(metaData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write cloud-init files to a directory
|
||||
*/
|
||||
async writeCloudInitFiles(
|
||||
outputDir: string,
|
||||
cloudInitConfig: ICloudInitConfig,
|
||||
networkConfig?: INetworkConfig,
|
||||
): Promise<void> {
|
||||
log.info('Generating cloud-init configuration files...');
|
||||
|
||||
// Generate user-data
|
||||
const userData = this.generateUserData(cloudInitConfig);
|
||||
const userDataPath = pathUtil.join(outputDir, 'user-data');
|
||||
await Deno.writeTextFile(userDataPath, userData);
|
||||
log.success('Generated user-data');
|
||||
|
||||
// Generate network-config
|
||||
if (networkConfig) {
|
||||
const netConfig = this.generateNetworkConfig(networkConfig);
|
||||
const netConfigPath = pathUtil.join(outputDir, 'network-config');
|
||||
await Deno.writeTextFile(netConfigPath, netConfig);
|
||||
log.success('Generated network-config');
|
||||
}
|
||||
|
||||
// Generate meta-data
|
||||
const metaData = this.generateMetaData(cloudInitConfig.hostname);
|
||||
const metaDataPath = pathUtil.join(outputDir, 'meta-data');
|
||||
await Deno.writeTextFile(metaDataPath, metaData);
|
||||
log.success('Generated meta-data');
|
||||
}
|
||||
}
|
||||
133
ts/classes/config-manager.ts
Normal file
133
ts/classes/config-manager.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Config Manager
|
||||
* Loads and validates YAML configuration files
|
||||
*/
|
||||
|
||||
import { yaml } from '../plugins.ts';
|
||||
import { log } from '../logging.ts';
|
||||
import type { IIsoConfig } from '../interfaces/iso-config.interface.ts';
|
||||
|
||||
export class ConfigManager {
|
||||
/**
|
||||
* Load config from YAML file
|
||||
*/
|
||||
async loadFromFile(filePath: string): Promise<IIsoConfig> {
|
||||
log.info(`Loading configuration from ${filePath}...`);
|
||||
|
||||
try {
|
||||
const content = await Deno.readTextFile(filePath);
|
||||
const config = yaml.parse(content) as IIsoConfig;
|
||||
|
||||
// Validate
|
||||
this.validate(config);
|
||||
|
||||
log.success('Configuration loaded and validated');
|
||||
return config;
|
||||
} catch (err) {
|
||||
if (err instanceof Deno.errors.NotFound) {
|
||||
throw new Error(`Config file not found: ${filePath}`);
|
||||
}
|
||||
throw new Error(`Failed to load config: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate configuration
|
||||
*/
|
||||
validate(config: IIsoConfig): void {
|
||||
const errors: string[] = [];
|
||||
|
||||
// Version check
|
||||
if (!config.version) {
|
||||
errors.push('Missing required field: version');
|
||||
}
|
||||
|
||||
// ISO settings
|
||||
if (!config.iso) {
|
||||
errors.push('Missing required field: iso');
|
||||
} else {
|
||||
if (!config.iso.ubuntu_version) {
|
||||
errors.push('Missing required field: iso.ubuntu_version');
|
||||
}
|
||||
if (!config.iso.architecture || !['amd64', 'arm64'].includes(config.iso.architecture)) {
|
||||
errors.push('Invalid or missing field: iso.architecture (must be "amd64" or "arm64")');
|
||||
}
|
||||
}
|
||||
|
||||
// Output settings
|
||||
if (!config.output) {
|
||||
errors.push('Missing required field: output');
|
||||
} else {
|
||||
if (!config.output.filename) {
|
||||
errors.push('Missing required field: output.filename');
|
||||
}
|
||||
if (!config.output.path) {
|
||||
errors.push('Missing required field: output.path');
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`Configuration validation failed:\n${errors.join('\n')}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a template configuration
|
||||
*/
|
||||
generateTemplate(): string {
|
||||
const template: IIsoConfig = {
|
||||
version: '1.0',
|
||||
iso: {
|
||||
ubuntu_version: '24.04',
|
||||
architecture: 'amd64',
|
||||
flavor: 'server',
|
||||
},
|
||||
output: {
|
||||
filename: 'ubuntu-custom.iso',
|
||||
path: './output',
|
||||
},
|
||||
network: {
|
||||
wifi: {
|
||||
ssid: 'MyWiFi',
|
||||
password: 'changeme',
|
||||
},
|
||||
},
|
||||
cloud_init: {
|
||||
hostname: 'ubuntu-server',
|
||||
users: [
|
||||
{
|
||||
name: 'admin',
|
||||
ssh_authorized_keys: [
|
||||
'ssh-rsa AAAAB3... your-key-here',
|
||||
],
|
||||
sudo: 'ALL=(ALL) NOPASSWD:ALL',
|
||||
shell: '/bin/bash',
|
||||
},
|
||||
],
|
||||
packages: [
|
||||
'docker.io',
|
||||
'git',
|
||||
'htop',
|
||||
],
|
||||
runcmd: [
|
||||
'systemctl enable docker',
|
||||
'systemctl start docker',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
return `# isocreator configuration file
|
||||
# Version: 1.0
|
||||
|
||||
${yaml.stringify(template)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save template to file
|
||||
*/
|
||||
async saveTemplate(filePath: string): Promise<void> {
|
||||
const template = this.generateTemplate();
|
||||
await Deno.writeTextFile(filePath, template);
|
||||
log.success(`Template saved to ${filePath}`);
|
||||
}
|
||||
}
|
||||
153
ts/classes/iso-builder.ts
Normal file
153
ts/classes/iso-builder.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* ISO Builder Orchestrator
|
||||
* Coordinates all ISO building operations
|
||||
*/
|
||||
|
||||
import { log } from '../logging.ts';
|
||||
import { path } from '../plugins.ts';
|
||||
import { ensureDir } from '../paths.ts';
|
||||
import type { IIsoConfig } from '../interfaces/iso-config.interface.ts';
|
||||
|
||||
import { IsoCache } from './iso-cache.ts';
|
||||
import { IsoDownloader } from './iso-downloader.ts';
|
||||
import { IsoExtractor } from './iso-extractor.ts';
|
||||
import { IsoPacker } from './iso-packer.ts';
|
||||
import { CloudInitGenerator } from './cloud-init-generator.ts';
|
||||
|
||||
export class IsoBuilder {
|
||||
private cache: IsoCache;
|
||||
private extractor: IsoExtractor;
|
||||
private packer: IsoPacker;
|
||||
private cloudInitGen: CloudInitGenerator;
|
||||
|
||||
constructor() {
|
||||
this.cache = new IsoCache();
|
||||
this.extractor = new IsoExtractor();
|
||||
this.packer = new IsoPacker();
|
||||
this.cloudInitGen = new CloudInitGenerator();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a customized ISO from configuration
|
||||
*/
|
||||
async build(config: IIsoConfig, onProgress?: (step: string, progress: number) => void): Promise<void> {
|
||||
log.info('Starting ISO build process...');
|
||||
|
||||
// Step 1: Check dependencies
|
||||
onProgress?.('Checking dependencies', 10);
|
||||
await this.extractor.ensureDependencies();
|
||||
await this.packer.ensureDependencies();
|
||||
|
||||
// Step 2: Get or download base ISO
|
||||
onProgress?.('Obtaining base ISO', 20);
|
||||
const baseIsoPath = await this.getBaseIso(config.iso.ubuntu_version, config.iso.architecture);
|
||||
|
||||
// Step 3: Extract ISO
|
||||
onProgress?.('Extracting ISO', 40);
|
||||
const extractedDir = await this.extractor.extract(baseIsoPath);
|
||||
|
||||
// Step 4: Generate cloud-init configuration
|
||||
onProgress?.('Generating cloud-init configuration', 60);
|
||||
const cloudInitDir = path.join(extractedDir, 'nocloud');
|
||||
await ensureDir(cloudInitDir);
|
||||
|
||||
if (config.cloud_init) {
|
||||
const networkConfig = config.network ? {
|
||||
wifi: config.network.wifi,
|
||||
} : undefined;
|
||||
|
||||
await this.cloudInitGen.writeCloudInitFiles(
|
||||
cloudInitDir,
|
||||
config.cloud_init,
|
||||
networkConfig,
|
||||
);
|
||||
}
|
||||
|
||||
// Step 5: Inject custom boot scripts (if any)
|
||||
if (config.boot_scripts && config.boot_scripts.length > 0) {
|
||||
onProgress?.('Injecting boot scripts', 70);
|
||||
await this.injectBootScripts(extractedDir, config.boot_scripts);
|
||||
}
|
||||
|
||||
// Step 6: Update grub configuration to use cloud-init
|
||||
onProgress?.('Updating boot configuration', 80);
|
||||
await this.updateBootConfig(extractedDir);
|
||||
|
||||
// Step 7: Repack ISO
|
||||
onProgress?.('Creating customized ISO', 90);
|
||||
const outputPath = path.join(config.output.path, config.output.filename);
|
||||
await ensureDir(config.output.path);
|
||||
await this.packer.pack(extractedDir, outputPath, 'UBUNTU_CUSTOM');
|
||||
|
||||
// Step 8: Cleanup
|
||||
onProgress?.('Cleaning up', 95);
|
||||
await Deno.remove(extractedDir, { recursive: true });
|
||||
|
||||
onProgress?.('Complete', 100);
|
||||
log.success(`ISO built successfully: ${outputPath}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get base ISO (from cache or download)
|
||||
*/
|
||||
private async getBaseIso(version: string, arch: 'amd64' | 'arm64'): Promise<string> {
|
||||
// Check cache first
|
||||
const cachedPath = await this.cache.getPath(version, arch);
|
||||
if (cachedPath) {
|
||||
log.info(`Using cached ISO: ${cachedPath}`);
|
||||
return cachedPath;
|
||||
}
|
||||
|
||||
// Download to cache
|
||||
log.info(`Downloading Ubuntu ${version} ${arch}...`);
|
||||
return await this.cache.downloadAndCache(version, arch, (downloaded, total) => {
|
||||
const percent = ((downloaded / total) * 100).toFixed(1);
|
||||
process.stdout.write(`\r📥 Download progress: ${percent}%`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject custom boot scripts
|
||||
*/
|
||||
private async injectBootScripts(
|
||||
extractedDir: string,
|
||||
bootScripts: Array<{ name: string; path: string; enable?: boolean }>,
|
||||
): Promise<void> {
|
||||
for (const script of bootScripts) {
|
||||
log.info(`Injecting boot script: ${script.name}`);
|
||||
|
||||
// Copy script to ISO
|
||||
const destPath = path.join(extractedDir, 'scripts', script.name);
|
||||
await ensureDir(path.dirname(destPath));
|
||||
await Deno.copyFile(script.path, destPath);
|
||||
|
||||
// Make executable
|
||||
await Deno.chmod(destPath, 0o755);
|
||||
|
||||
// TODO: Create systemd service if enable is true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update boot configuration to enable cloud-init with nocloud datasource
|
||||
*/
|
||||
private async updateBootConfig(extractedDir: string): Promise<void> {
|
||||
// Update grub config to add cloud-init nocloud datasource
|
||||
const grubCfgPath = path.join(extractedDir, 'boot', 'grub', 'grub.cfg');
|
||||
|
||||
try {
|
||||
let grubContent = await Deno.readTextFile(grubCfgPath);
|
||||
|
||||
// Add cloud-init datasource parameter
|
||||
grubContent = grubContent.replace(
|
||||
/linux\s+\/casper\/vmlinuz/g,
|
||||
'linux /casper/vmlinuz ds=nocloud;s=/cdrom/nocloud/',
|
||||
);
|
||||
|
||||
await Deno.writeTextFile(grubCfgPath, grubContent);
|
||||
log.success('Updated boot configuration for cloud-init');
|
||||
} catch (err) {
|
||||
log.warn(`Could not update grub config: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
212
ts/classes/iso-cache.ts
Normal file
212
ts/classes/iso-cache.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* ISO Cache Manager
|
||||
* Manages cached Ubuntu ISOs with multi-version support
|
||||
*/
|
||||
|
||||
import { log } from '../logging.ts';
|
||||
import { path } from '../plugins.ts';
|
||||
import { getCacheDir, ensureDir } from '../paths.ts';
|
||||
import { IsoDownloader } from './iso-downloader.ts';
|
||||
|
||||
export interface ICacheEntry {
|
||||
version: string;
|
||||
architecture: 'amd64' | 'arm64';
|
||||
filename: string;
|
||||
path: string;
|
||||
size: number;
|
||||
downloadedAt: Date;
|
||||
}
|
||||
|
||||
export class IsoCache {
|
||||
private cacheDir: string;
|
||||
private metadataPath: string;
|
||||
|
||||
constructor() {
|
||||
this.cacheDir = getCacheDir();
|
||||
this.metadataPath = path.join(this.cacheDir, 'metadata.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the cache directory
|
||||
*/
|
||||
async init(): Promise<void> {
|
||||
await ensureDir(this.cacheDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all cache entries
|
||||
*/
|
||||
async list(): Promise<ICacheEntry[]> {
|
||||
try {
|
||||
const metadata = await Deno.readTextFile(this.metadataPath);
|
||||
return JSON.parse(metadata);
|
||||
} catch (err) {
|
||||
if (err instanceof Deno.errors.NotFound) {
|
||||
return [];
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific cache entry
|
||||
*/
|
||||
async get(version: string, architecture: 'amd64' | 'arm64'): Promise<ICacheEntry | null> {
|
||||
const entries = await this.list();
|
||||
return entries.find((e) => e.version === version && e.architecture === architecture) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an ISO is cached
|
||||
*/
|
||||
async has(version: string, architecture: 'amd64' | 'arm64'): Promise<boolean> {
|
||||
const entry = await this.get(version, architecture);
|
||||
if (!entry) return false;
|
||||
|
||||
// Verify the file still exists
|
||||
try {
|
||||
const stat = await Deno.stat(entry.path);
|
||||
return stat.isFile;
|
||||
} catch {
|
||||
// File doesn't exist, remove from metadata
|
||||
await this.remove(version, architecture);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an ISO to the cache
|
||||
*/
|
||||
async add(
|
||||
version: string,
|
||||
architecture: 'amd64' | 'arm64',
|
||||
sourcePath: string,
|
||||
): Promise<ICacheEntry> {
|
||||
await this.init();
|
||||
|
||||
const filename = IsoDownloader.getIsoFilename(version, architecture);
|
||||
const destPath = path.join(this.cacheDir, filename);
|
||||
|
||||
// Copy file to cache
|
||||
await Deno.copyFile(sourcePath, destPath);
|
||||
|
||||
// Get file size
|
||||
const stat = await Deno.stat(destPath);
|
||||
|
||||
const entry: ICacheEntry = {
|
||||
version,
|
||||
architecture,
|
||||
filename,
|
||||
path: destPath,
|
||||
size: stat.size,
|
||||
downloadedAt: new Date(),
|
||||
};
|
||||
|
||||
// Update metadata
|
||||
const entries = await this.list();
|
||||
const existingIndex = entries.findIndex(
|
||||
(e) => e.version === version && e.architecture === architecture,
|
||||
);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
entries[existingIndex] = entry;
|
||||
} else {
|
||||
entries.push(entry);
|
||||
}
|
||||
|
||||
await this.saveMetadata(entries);
|
||||
log.success(`ISO cached: ${version} ${architecture}`);
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an ISO from the cache
|
||||
*/
|
||||
async remove(version: string, architecture: 'amd64' | 'arm64'): Promise<void> {
|
||||
const entry = await this.get(version, architecture);
|
||||
if (!entry) {
|
||||
log.warn(`ISO not found in cache: ${version} ${architecture}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove file
|
||||
try {
|
||||
await Deno.remove(entry.path);
|
||||
} catch (err) {
|
||||
if (!(err instanceof Deno.errors.NotFound)) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Update metadata
|
||||
const entries = await this.list();
|
||||
const filtered = entries.filter(
|
||||
(e) => !(e.version === version && e.architecture === architecture),
|
||||
);
|
||||
await this.saveMetadata(filtered);
|
||||
|
||||
log.success(`ISO removed from cache: ${version} ${architecture}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean old cached ISOs
|
||||
*/
|
||||
async clean(olderThanDays?: number): Promise<void> {
|
||||
const entries = await this.list();
|
||||
const now = new Date();
|
||||
|
||||
for (const entry of entries) {
|
||||
const age = now.getTime() - new Date(entry.downloadedAt).getTime();
|
||||
const ageDays = age / (1000 * 60 * 60 * 24);
|
||||
|
||||
if (!olderThanDays || ageDays > olderThanDays) {
|
||||
await this.remove(entry.version, entry.architecture);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to a cached ISO
|
||||
*/
|
||||
async getPath(version: string, architecture: 'amd64' | 'arm64'): Promise<string | null> {
|
||||
const entry = await this.get(version, architecture);
|
||||
return entry?.path || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download and cache an ISO
|
||||
*/
|
||||
async downloadAndCache(
|
||||
version: string,
|
||||
architecture: 'amd64' | 'arm64',
|
||||
onProgress?: (downloaded: number, total: number) => void,
|
||||
): Promise<string> {
|
||||
await this.init();
|
||||
|
||||
const filename = IsoDownloader.getIsoFilename(version, architecture);
|
||||
const destPath = path.join(this.cacheDir, filename);
|
||||
|
||||
const downloader = new IsoDownloader();
|
||||
|
||||
await downloader.downloadWithVerification({
|
||||
ubuntuVersion: version,
|
||||
architecture,
|
||||
outputPath: destPath,
|
||||
onProgress,
|
||||
});
|
||||
|
||||
// Add to cache metadata
|
||||
await this.add(version, architecture, destPath);
|
||||
|
||||
return destPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save metadata to disk
|
||||
*/
|
||||
private async saveMetadata(entries: ICacheEntry[]): Promise<void> {
|
||||
await ensureDir(this.cacheDir);
|
||||
await Deno.writeTextFile(this.metadataPath, JSON.stringify(entries, null, 2));
|
||||
}
|
||||
}
|
||||
172
ts/classes/iso-downloader.ts
Normal file
172
ts/classes/iso-downloader.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* ISO Downloader
|
||||
* Downloads Ubuntu Server ISOs from official mirrors
|
||||
*/
|
||||
|
||||
import { log } from '../logging.ts';
|
||||
import { path } from '../plugins.ts';
|
||||
|
||||
export interface IDownloadOptions {
|
||||
ubuntuVersion: string; // e.g., "24.04", "22.04"
|
||||
architecture: 'amd64' | 'arm64';
|
||||
outputPath: string;
|
||||
onProgress?: (downloaded: number, total: number) => void;
|
||||
}
|
||||
|
||||
export class IsoDownloader {
|
||||
/**
|
||||
* Ubuntu release URLs
|
||||
*/
|
||||
private static readonly UBUNTU_MIRROR = 'https://releases.ubuntu.com';
|
||||
|
||||
/**
|
||||
* Get the ISO filename for a given version and architecture
|
||||
*/
|
||||
static getIsoFilename(version: string, arch: 'amd64' | 'arm64'): string {
|
||||
// Ubuntu Server ISO naming pattern
|
||||
if (arch === 'amd64') {
|
||||
return `ubuntu-${version}-live-server-${arch}.iso`;
|
||||
} else {
|
||||
// ARM64 uses preinstalled server images
|
||||
return `ubuntu-${version}-preinstalled-server-${arch}.img.xz`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the download URL for a given Ubuntu version and architecture
|
||||
*/
|
||||
static getDownloadUrl(version: string, arch: 'amd64' | 'arm64'): string {
|
||||
const filename = this.getIsoFilename(version, arch);
|
||||
return `${this.UBUNTU_MIRROR}/${version}/${filename}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the checksum URL for verification
|
||||
*/
|
||||
static getChecksumUrl(version: string): string {
|
||||
return `${this.UBUNTU_MIRROR}/${version}/SHA256SUMS`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download an Ubuntu ISO
|
||||
*/
|
||||
async download(options: IDownloadOptions): Promise<void> {
|
||||
const { ubuntuVersion, architecture, outputPath, onProgress } = options;
|
||||
const url = IsoDownloader.getDownloadUrl(ubuntuVersion, architecture);
|
||||
|
||||
log.info(`Downloading Ubuntu ${ubuntuVersion} ${architecture} from ${url}`);
|
||||
|
||||
// Download the ISO
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download ISO: HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const totalSize = parseInt(response.headers.get('content-length') || '0', 10);
|
||||
let downloadedSize = 0;
|
||||
|
||||
// Open file for writing
|
||||
const file = await Deno.open(outputPath, { write: true, create: true, truncate: true });
|
||||
|
||||
try {
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
throw new Error('Failed to get response body reader');
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) break;
|
||||
|
||||
await file.write(value);
|
||||
downloadedSize += value.length;
|
||||
|
||||
if (onProgress && totalSize > 0) {
|
||||
onProgress(downloadedSize, totalSize);
|
||||
}
|
||||
}
|
||||
|
||||
log.success(`ISO downloaded successfully to ${outputPath}`);
|
||||
} finally {
|
||||
file.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download and verify checksum
|
||||
*/
|
||||
async downloadWithVerification(options: IDownloadOptions): Promise<void> {
|
||||
const { ubuntuVersion, architecture, outputPath } = options;
|
||||
|
||||
// Download the ISO
|
||||
await this.download(options);
|
||||
|
||||
// Download checksums
|
||||
log.info('Downloading checksums for verification...');
|
||||
const checksumUrl = IsoDownloader.getChecksumUrl(ubuntuVersion);
|
||||
const checksumResponse = await fetch(checksumUrl);
|
||||
|
||||
if (!checksumResponse.ok) {
|
||||
log.warn('Could not download checksums for verification');
|
||||
return;
|
||||
}
|
||||
|
||||
const checksumText = await checksumResponse.text();
|
||||
const filename = path.basename(outputPath);
|
||||
|
||||
// Find the checksum for our file
|
||||
const lines = checksumText.split('\n');
|
||||
let expectedChecksum: string | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.includes(filename)) {
|
||||
expectedChecksum = line.split(/\s+/)[0];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!expectedChecksum) {
|
||||
log.warn(`No checksum found for ${filename}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify the checksum
|
||||
log.info('Verifying checksum...');
|
||||
const actualChecksum = await this.calculateSha256(outputPath);
|
||||
|
||||
if (actualChecksum === expectedChecksum) {
|
||||
log.success('Checksum verified successfully ✓');
|
||||
} else {
|
||||
throw new Error(`Checksum mismatch!\nExpected: ${expectedChecksum}\nActual: ${actualChecksum}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate SHA256 checksum of a file
|
||||
*/
|
||||
private async calculateSha256(filePath: string): Promise<string> {
|
||||
const file = await Deno.open(filePath, { read: true });
|
||||
const buffer = new Uint8Array(8192);
|
||||
const hasher = await crypto.subtle.digest('SHA-256', new Uint8Array(0)); // Initialize
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const bytesRead = await file.read(buffer);
|
||||
if (bytesRead === null) break;
|
||||
|
||||
// Hash the chunk
|
||||
const chunk = buffer.slice(0, bytesRead);
|
||||
await crypto.subtle.digest('SHA-256', chunk);
|
||||
}
|
||||
} finally {
|
||||
file.close();
|
||||
}
|
||||
|
||||
// Convert to hex string
|
||||
const hashArray = Array.from(new Uint8Array(hasher));
|
||||
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
return hashHex;
|
||||
}
|
||||
}
|
||||
82
ts/classes/iso-extractor.ts
Normal file
82
ts/classes/iso-extractor.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* ISO Extractor
|
||||
* Extracts ISO contents using xorriso
|
||||
*/
|
||||
|
||||
import { log } from '../logging.ts';
|
||||
import { getTempDir, ensureDir, cleanDir } from '../paths.ts';
|
||||
import { path } from '../plugins.ts';
|
||||
|
||||
export class IsoExtractor {
|
||||
/**
|
||||
* Extract an ISO to a directory
|
||||
*/
|
||||
async extract(isoPath: string, extractDir?: string): Promise<string> {
|
||||
// Use temp dir if not specified
|
||||
const outputDir = extractDir || path.join(getTempDir(), `iso-extract-${Date.now()}`);
|
||||
|
||||
await cleanDir(outputDir);
|
||||
await ensureDir(outputDir);
|
||||
|
||||
log.info(`Extracting ISO to ${outputDir}...`);
|
||||
|
||||
// Use xorriso to extract the ISO
|
||||
const command = new Deno.Command('xorriso', {
|
||||
args: [
|
||||
'-osirrox',
|
||||
'on',
|
||||
'-indev',
|
||||
isoPath,
|
||||
'-extract',
|
||||
'/',
|
||||
outputDir,
|
||||
],
|
||||
stdout: 'piped',
|
||||
stderr: 'piped',
|
||||
});
|
||||
|
||||
const process = command.spawn();
|
||||
const { code, stdout, stderr } = await process.output();
|
||||
|
||||
if (code !== 0) {
|
||||
const errorText = new TextDecoder().decode(stderr);
|
||||
throw new Error(`Failed to extract ISO: ${errorText}`);
|
||||
}
|
||||
|
||||
log.success(`ISO extracted successfully to ${outputDir}`);
|
||||
return outputDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if xorriso is installed
|
||||
*/
|
||||
async checkDependencies(): Promise<boolean> {
|
||||
try {
|
||||
const command = new Deno.Command('xorriso', {
|
||||
args: ['--version'],
|
||||
stdout: 'null',
|
||||
stderr: 'null',
|
||||
});
|
||||
|
||||
const { code } = await command.output();
|
||||
return code === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure xorriso is installed
|
||||
*/
|
||||
async ensureDependencies(): Promise<void> {
|
||||
const hasXorriso = await this.checkDependencies();
|
||||
|
||||
if (!hasXorriso) {
|
||||
log.error('xorriso is not installed!');
|
||||
log.info('Install xorriso:');
|
||||
log.info(' Ubuntu/Debian: sudo apt install xorriso');
|
||||
log.info(' macOS: brew install xorriso');
|
||||
throw new Error('Missing dependency: xorriso');
|
||||
}
|
||||
}
|
||||
}
|
||||
123
ts/classes/iso-packer.ts
Normal file
123
ts/classes/iso-packer.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* ISO Packer
|
||||
* Repacks a directory into a bootable ISO using xorriso
|
||||
*/
|
||||
|
||||
import { log } from '../logging.ts';
|
||||
|
||||
export class IsoPacker {
|
||||
/**
|
||||
* Pack a directory into an ISO
|
||||
*/
|
||||
async pack(sourceDir: string, outputIso: string, volumeLabel?: string): Promise<void> {
|
||||
log.info(`Creating ISO from ${sourceDir}...`);
|
||||
|
||||
const label = volumeLabel || 'UBUNTU_CUSTOM';
|
||||
|
||||
// Use xorriso to create a bootable ISO
|
||||
const command = new Deno.Command('xorriso', {
|
||||
args: [
|
||||
'-as',
|
||||
'mkisofs',
|
||||
'-r',
|
||||
'-V',
|
||||
label,
|
||||
'-o',
|
||||
outputIso,
|
||||
'-J',
|
||||
'-joliet-long',
|
||||
'-cache-inodes',
|
||||
'-isohybrid-mbr',
|
||||
'/usr/lib/ISOLINUX/isohdpfx.bin',
|
||||
'-b',
|
||||
'isolinux/isolinux.bin',
|
||||
'-c',
|
||||
'isolinux/boot.cat',
|
||||
'-boot-load-size',
|
||||
'4',
|
||||
'-boot-info-table',
|
||||
'-no-emul-boot',
|
||||
'-eltorito-alt-boot',
|
||||
'-e',
|
||||
'boot/grub/efi.img',
|
||||
'-no-emul-boot',
|
||||
'-isohybrid-gpt-basdat',
|
||||
sourceDir,
|
||||
],
|
||||
stdout: 'piped',
|
||||
stderr: 'piped',
|
||||
});
|
||||
|
||||
const process = command.spawn();
|
||||
const { code, stderr } = await process.output();
|
||||
|
||||
if (code !== 0) {
|
||||
const errorText = new TextDecoder().decode(stderr);
|
||||
throw new Error(`Failed to create ISO: ${errorText}`);
|
||||
}
|
||||
|
||||
log.success(`ISO created successfully at ${outputIso}`);
|
||||
|
||||
// Make it hybrid bootable (USB compatible)
|
||||
await this.makeIsohybrid(outputIso);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make the ISO hybrid bootable (USB-compatible)
|
||||
*/
|
||||
private async makeIsohybrid(isoPath: string): Promise<void> {
|
||||
try {
|
||||
log.info('Making ISO hybrid bootable...');
|
||||
|
||||
const command = new Deno.Command('isohybrid', {
|
||||
args: ['--uefi', isoPath],
|
||||
stdout: 'piped',
|
||||
stderr: 'piped',
|
||||
});
|
||||
|
||||
const { code } = await command.output();
|
||||
|
||||
if (code === 0) {
|
||||
log.success('ISO is now USB-bootable');
|
||||
} else {
|
||||
log.warn('isohybrid failed, ISO may not be USB-bootable');
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
log.warn(`isohybrid not available: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if required tools are installed
|
||||
*/
|
||||
async checkDependencies(): Promise<boolean> {
|
||||
try {
|
||||
const xorrisoCmd = new Deno.Command('xorriso', {
|
||||
args: ['--version'],
|
||||
stdout: 'null',
|
||||
stderr: 'null',
|
||||
});
|
||||
|
||||
const { code } = await xorrisoCmd.output();
|
||||
return code === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure dependencies are installed
|
||||
*/
|
||||
async ensureDependencies(): Promise<void> {
|
||||
const hasXorriso = await this.checkDependencies();
|
||||
|
||||
if (!hasXorriso) {
|
||||
log.error('xorriso is not installed!');
|
||||
log.info('Install xorriso:');
|
||||
log.info(' Ubuntu/Debian: sudo apt install xorriso syslinux-utils');
|
||||
log.info(' macOS: brew install xorriso syslinux');
|
||||
throw new Error('Missing dependency: xorriso');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user