Ulfs Blog

17.1.2019, 18:30

WSL

Ulfs Blog

Um Entwicklungsumgebungen komplett unabhängig voneinander auf einem Windows-Rechner einzurichten, kann man virtuelle Maschinen einrichten. Eine kostenlose Software dafür ist VirtualBox. Die andere große Alternative ist VMware (Workstation und Player). Falls man keine grafische Oberfläche braucht (z. B. für die Entwicklung von Programmen die im Webbrowser laufen), dann gibt es mit dem Windows Subsystem for Linux (WSL) eine weitere und einfachere Alternative.

WSL setzt auch auf Virtualisierung, allerdings werden viele Funktionen schon frühzeitig an den Windows-Kernel weiter gereicht. Es lassen sich deshalb auch nur speziell angepasste Linuxversionen verwenden. Der Vorteil ist, dass man mit WSL unter Linux nicht nur Zugriff auf die Verzeichnisse von Windows hat (das geht mit Gemeinsamen Ordnern unter VirtualBox auch), sondern die Dateiüberwachung in beide Richtungen funktioniert.

Die folgenden Skripte gehen davon aus, dass es ein Projektverzeichnis gibt, dessen Name auch der Name des Projektes ist (möglichst ohne Sonder- und Leerzeichen). In diesem Verzeichnis befindet sich ein Unterverzeichnis (Name egal, z. B. .wsl), welches alle Skripte enthält. Die Skripte lesen den Projektnamen dann automatisch aus dem Verzeichnispfad, in dem sie sich befinden. In der Linux-Umgebung ist das Projektverzeichnis dann per Softlink als ~/project verfügbar.

Installation

Zur Installation muss das optionale Feature WSL nachinstalliert werden. Dafür gibt es einen PowerShell-Befehl, der allerdings als Administrator aufgerufen werden muss. Das folgende Skript speichert man als activate-wsl-runas-admin.ps1 und führt es dann aus:

param([switch]$elevated)

function Test-Admin {
	$currentUser = New-Object Security.Principal.WindowsPrincipal $([Security.Principal.WindowsIdentity]::GetCurrent())
	$currentUser.IsInRole([Security.Principal.WindowsBuiltinRole]::Administrator)
}

if ((Test-Admin) -eq $false) {
	if ($elevated) 
	{
		# tried to elevate, did not work, aborting
		exit
    } 
	Start-Process powershell.exe -Verb RunAs -ArgumentList ('-noprofile -noexit -file "{0}" -elevated' -f ($myinvocation.MyCommand.Definition))
	exit
}

#install WSL
Enable-WindowsOptionalFeature -Online -FeatureName Microsoft-Windows-Subsystem-Linux

In den weiteren Skripts könnte man mit (Get-WindowsOptionalFeature -Online -FeatureName Microsoft-Windows-Subsystem-Linux).State -eq "Disabled") überprüfen, ob WSL installiert ist und falls das nicht der Fall ist, das Skript activate-wsl-runas-admin.ps1 ausführen. Leider steht der Befehl Get-WindowsOptionalFeature nicht in allen Windows-Versionen ohne Administratorrechte zur Verfügung.

Linux herunterladen

Für mich habe ich die Distribution Ubuntu 18.04 gewählt. Der Download und das Entpacken lässt sich wieder mit einem PowerShell-Skript download-ubuntu.ps1 durchführen:

$wslPath = $env:LOCALAPPDATA + '\WSL'
$zipFile = $wslPath + '\ubuntu-18.04.zip'
$ubuntuPath = $wslPath + '\ubuntu-18.04'
$ubuntuExe = $ubuntuPath + '\ubuntu1804.exe'
$ubuntuTarGz = $ubuntuPath + '\install.tar.gz'
$ErrorActionPreference = "Stop"

# Check if Ubuntu is already installed
if ((Test-Path $ubuntuExe) -And (Test-Path $ubuntuTarGz))
{
	exit
}

# Create folder
New-Item -ItemType directory -Force -Path $wslPath

# Download file as zip
Invoke-WebRequest -Uri https://aka.ms/wsl-ubuntu-1804 -OutFile $zipFile -UseBasicParsing

# Unzip file
Expand-Archive $zipFile -DestinationPath $ubuntuPath

# Delete zip file
Remove-Item $zipFile

Theoretisch kann man Ubuntu 18.04 jetzt mit dem Aufruf von ubuntu1804.exe installieren. Allerdings möchte ich unterschiedliche Instanzen für unterschiedliche Projekte haben. Dafür muss Ubuntu aber mehrfach installiert werden.

Linux (mehrfach) installieren

Das Programm im Ubuntu-Ordner führt beim ersten Aufruf eine Installation durch. Bei folgenden Aufrufen wird dann das installierte Linux gestartet. Um mehrere Installationen durchzuführen, müssen wir die Installationsdateien in ein extra Verzeichnis kopieren und nach der Installation die Registry anpassen. Dies macht folgendes Skript install-ubuntu.ps1:

$InstanceName = Split-Path -Leaf (Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Definition))

$wslPath = $env:LOCALAPPDATA + '\WSL'
$ubuntuDistName = 'Ubuntu-18.04'
$ubuntuPath = $wslPath + '\ubuntu-18.04'
$ubuntuExe = $ubuntuPath + '\ubuntu1804.exe'
$ubuntuTarGz = $ubuntuPath + '\install.tar.gz'
$destPath = $wslPath + '\' + $InstanceName
$destExe = $destPath + '\ubuntu1804.exe'
$destTarGz = $destPath + '\install.tar.gz'
$ErrorActionPreference = "Stop"

# Check if copy exists
if ((Test-Path $destExe) -And (Test-Path $destTarGz))
{
	exit
}

# Check if Ubuntu exists
if (!((Test-Path $ubuntuExe) -And (Test-Path $ubuntuTarGz)))
{
	Invoke-Expression '.\download-ubuntu.ps1'
	if (!((Test-Path $ubuntuExe) -And (Test-Path $ubuntuTarGz)))
	{
		exit
	}
}

# Create folder
New-Item -ItemType directory -Force -Path $destPath

# Copy files
Copy-Item -Path $ubuntuExe -Destination $destExe
Copy-Item -Path $ubuntuTarGz -Destination $destTarGz

# Install Ubuntu
Invoke-Expression $destExe

# Change the registry value for the recently installed Ubuntu version to $InstanceName
$lxss = Get-ChildItem -Path "HKCU:\Software\Microsoft\Windows\CurrentVersion\Lxss" -Recurse
ForEach($item in $lxss) {
	$distName = Get-ItemProperty $item.PSPath | Select 'DistributionName'
	$basePath = Get-ItemProperty $item.PSPath | Select 'BasePath'
	If (($distName -imatch [Regex]::Escape($ubuntuDistName)) -And ($basePath -imatch [Regex]::Escape($destPath)))
	{
		Set-ItemProperty -Path $item.PSPath -Name "DistributionName" -Value $InstanceName -Force
		Write-Host "$distName -> $InstanceName"
		Break
	}
}

Bei der Installation von Ubuntu muss man einen Benutzernamen und ein Passwort eingeben. Beides sollte man sich natürlich gut merken. Allerdings ist es nicht nötig, sich ein extra Passwort auszudenken, weil man sich auch von außen Root-Zugriff verschaffen kann. Nach der Installation befindet man sich in der Linux Konsole, die man mit exit erst verlassen muss, damit der Rest des Skripts ausgeführt werden kann.

Nach der gesamten Installation können wir Befehle unter der neuen Linux-Instanz ausführen. Es ist geplant, dass das Programm wsl.exe dafür einen Parameter -Distribution erhält. In meiner Windows-Version ist das noch nicht der Fall, deshalb habe ich ein kleines Skript run-command.ps1 geschrieben:

param([parameter(Mandatory=$true)][string]$Command)

$InstanceName = Split-Path -Leaf (Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Definition))

$ErrorActionPreference = "Stop"

# Import command from DLL
$MethodDefinition = @'
[DllImport("wslapi.dll", CharSet = CharSet.Unicode)]
public static extern uint WslLaunchInteractive(
	[MarshalAs(UnmanagedType.LPWStr)]string distributionName,
	[MarshalAs(UnmanagedType.LPWStr)]string command,
	[MarshalAs(UnmanagedType.U1)]bool useCurrentWorkingDirectory,
	out uint exitCode);
'@
$Name = 'WslApi' + (Get-Random)
$WslApi = Add-Type -MemberDefinition $MethodDefinition -Name $Name -Namespace 'Win32' -PassThru

# Invoke command
$exitCode = 0;
$WslApi::WslLaunchInteractive($InstanceName, $Command, $true, [ref] $exitCode)
exit $exitCode

Shell starten

Ein Skript shell.ps1, mit dem man direkt die Bash-Shell erreicht, sieht dann wie folgt aus:

# Check for Ubuntu
Invoke-Expression '.\install-ubuntu.ps1'

# Run shell
Invoke-Expression '.\run-command.ps1 ~'

Linux einrichten

Mit dem Skript run-command.ps1 lässt sich ein Upgrade- und Install-Skript install+upgrade.sh innerhalb der Linux-Instanz aufrufen. Da dies ein Bash-Skript ist, muss es Linux-Zeilenendencodes haben (LF) und nicht jene von Windows (CR+LF)!

#!/bin/sh

# update and upgrade Ubuntu
sudo apt update && sudo apt upgrade && sudo apt dist-upgrade

# create link to project folder in user's home folder
# this assumes, that the script install+upgrade.sh is located in a subfolder of the project (like .wsl)
PROJECT_PATH=$(dirname $(dirname $0))
rm -rf ~/project
ln -s $PROJECT_PATH ~/project

# install gcc
sudo apt-get install build-essential

# remove obsolete packages
sudo apt autoremove

Die benötigten Programme sollten am Ende vor dem Autoremove installiert werden (in diesem Fall gcc).

Der Aufruf des gesamten Install-Skripts lässt sich in einem kleinen PowerShell-Skript install+upgrade.ps1 durchführen. Es führt gleich die Konvertierung des aktuellen Pfades durch. Die Windows-Laufwerke sind in Linux unter /mnt/ eingebunden (z. B. /mnt/c/Users).

# Check for Ubuntu
Invoke-Expression '.\install-ubuntu.ps1'

# Run command
$scriptPath = (Split-Path -Parent $MyInvocation.MyCommand.Definition) -Replace '\\','/' -Replace '(.):','$1'
$scriptPath = '/mnt/' + ($scriptPath.substring(0, 1).tolower() + $scriptPath.substring(1))
Invoke-Expression ('.\run-command.ps1 ' + $scriptPath + '/install+upgrade.sh')
if ($LastExitCode -ne 0)
{
	Read-Host -Prompt "Press enter!"
}

NodeJS

Um NodeJS und NPM in einer aktuellen Version (10.14.1 bzw. 6.4.1) zu installieren, sollte ein zusätzliches Repository angegeben werden. Der folgende Code im Skript install+upgrade.sh sorgt dafür:

# add nodejs source
if [ ! -f ~/nodesource_setup.sh ]; then
	curl -sL https://deb.nodesource.com/setup_10.x -o ~/nodesource_setup.sh
fi
sudo bash nodesource_setup.sh
#install nodejs
sudo apt-get install nodejs

Installiert man npm, möchte man auch gleich die Abhängigkeiten des Projekts installieren: cd ~/project && npm install.

Sudoers

Damit man bei sudo nicht mehr das Passwort eingeben muss (muss man zwar immer nur einmal pro Session eingeben, was aber schon zuviel ist, wenn man z. B. jedes Mal die Datenbank starten will), sollte man eine Einstellung in /etc/sudoers ändern (Code für install+upgrade.sh):

# make sudo not ask for user's password
if [ ! -f /etc/sudoers.d/nopassword ]; then
	sudo bash -c "echo \"%sudo ALL=(ALL) NOPASSWD: ALL\" >> /etc/sudoers.d/nopassword"
	sudo chmod 440 /etc/sudoers.d/nopassword
fi

Programme starten

Um Programme unter Linux zu starten, kann man das PowerShell-Skript run-command.ps1 von oben verwenden. Als String-Parameter wird ein Bash-Befehl erwartet. Dies kann dann wie folgt aussehen (am Ende gibt es noch eine Abfrage, um Fehlermeldungen anzuzeigen):

Invoke-Expression '.\run-command.ps1 "cd ~/project && cmake . && make && find test -type f -name test_\* ! -name \*.\* | xargs -L 1 bash -c"'
if ($LastExitCode -ne 0)
{
	Read-Host -Prompt "Press enter!"
}

Filehandles

Zu beachten ist, dass die Anzahl der erlaubten Filehandles nicht dauerhaft geändert werden können. Generell scheint es noch Probleme mit ulimit -n zu geben. Der Aufruf von ulimit -n 65536 vor jedem Programmstart ist aber möglich.

Deinstallation einer Instanz

Um die Instanz wieder loszuwerden, müssen der Registry-Eintrag und das Verzeichnis rekursive gelöscht werden. Das folgende PowerShell-Skript remove.ps1 fügt noch eine Sicherheitsabfrage hinzu:

$InstanceName = Split-Path -Leaf (Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Definition))

$wslPath = $env:LOCALAPPDATA + '\WSL'
$destPath = $wslPath + '\' + $InstanceName
$destExe = $destPath + '\ubuntu1804.exe'
$destTarGz = $destPath + '\install.tar.gz'
$ErrorActionPreference = "Stop"

# Check if copy exists
if ((-Not (Test-Path $destExe)) -Or (-Not (Test-Path $destTarGz))) {
	exit
}

# Safety question
$response = ''
while (($response -ne "yes") -And ($response -ne "no")) {
	$response = Read-Host ('Remove ' + $InstanceName + '? (yes|no)')
}
if ($response -ne "yes") {
	exit
}

# Remove the registry folder for the instance
$lxss = Get-ChildItem -Path "HKCU:\Software\Microsoft\Windows\CurrentVersion\Lxss" -Recurse
ForEach($item in $lxss) {
	$distName = Get-ItemProperty $item.PSPath | Select 'DistributionName'
	$basePath = Get-ItemProperty $item.PSPath | Select 'BasePath'
	If (($distName -imatch [Regex]::Escape($InstanceName)) -And ($basePath -imatch [Regex]::Escape($destPath)))
	{
		Remove-Item -Path $item.PsPath -Recurse
		Write-host "Removed " + $item.PsPath
		Break
	}
}

# Remove folder
Remove-Item -Recurse -Force $destPath
Write-host "Removed " + $destPath