diff --git a/.github/actions/download-rootfs/action.yaml b/.github/actions/download-rootfs/action.yaml
index ac58e364..d6ed8125 100644
--- a/.github/actions/download-rootfs/action.yaml
+++ b/.github/actions/download-rootfs/action.yaml
@@ -75,17 +75,31 @@ runs:
foreach ($release in $releaseList) {
$data = $release -split "\s+"
+ if ($data.Count -lt 2) {
+ Write-Output "Error: not enough fields in release info: $release"
+ $allSuccess = $false
+ continue
+ }
+
# $name is the one in inputs.distros
$name = $data[0]
- $file = ".\${name}.tar.gz"
$url = $data[1]
+ if ($name.Length -eq 0 -or $url.Length -eq 0) {
+ Write-Output "Error: empty fields in release info: $release"
+ $allSuccess = $false
+ continue
+ }
+
+ $file = ".\${name}.tar.gz"
+
& ${{ github.action_path }}\download-rootfs.ps1 -Path "${file}" -URL "$url"
if ( ! $? ) {
$allSuccess = $false
Write-Output "Error: Could not download ${name}"
}
+ $allSuccess = $true
Write-Output "::endgroup::"
}
diff --git a/.github/workflows/build-pr.yaml b/.github/workflows/build-pr.yaml
index 35d9e080..7ba01725 100644
--- a/.github/workflows/build-pr.yaml
+++ b/.github/workflows/build-pr.yaml
@@ -25,7 +25,9 @@ jobs:
path: repo
- name: Copy under workDir (which has more space to build the package)
shell: bash
+ # Bash.exe wouldn't know how to handle the symlinks inside docs/ without the export below.
run: |
+ export MSYS=winsymlinks:nativestrict
mkdir -p $(dirname ${{ env.workDir }})
mv ${GITHUB_WORKSPACE}/repo ${{ env.workDir }}
cd ${{ env.workDir }}
diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml
index bc43edba..d5163700 100644
--- a/.github/workflows/e2e.yaml
+++ b/.github/workflows/e2e.yaml
@@ -4,7 +4,7 @@ on:
pull_request:
paths-ignore:
- docs/**
- - *.md
+ - "*.md"
concurrency: azure-vm
env:
@@ -28,9 +28,10 @@ jobs:
needs: vm-setup
env:
rootFsCache: "${env:USERPROFILE}\\Downloads\\rootfs"
- distroName: Ubuntu-Preview
- appID: UbuntuPreview
- launcher: ubuntupreview.exe
+ # TODO: Move this to "Ubuntu" once we have backported everything and Ubuntu is transitionned to 24.04
+ distroName: Ubuntu-24.04
+ appID: Ubuntu24.04LTS
+ launcher: ubuntu2404.exe
steps:
- name: Checkout
uses: actions/checkout@v3
@@ -88,7 +89,7 @@ jobs:
Write-Output "::endgroup::"
Write-Output "::group::Tests"
- go test .\launchertester\ -timeout 15m -run TestBasicSetup --distro-name '${{ env.distroName }}' --launcher-name '${{ env.launcher }}'
+ go test .\launchertester\ -timeout 30m --distro-name '${{ env.distroName }}' --launcher-name '${{ env.launcher }}'
$exitStatus=$?
Write-Output "::endgroup::"
if ( ! $exitStatus ) { Exit(1) }
diff --git a/DistroLauncher/DistroLauncher.cpp b/DistroLauncher/DistroLauncher.cpp
index 4b0da554..4c04ab2e 100644
--- a/DistroLauncher/DistroLauncher.cpp
+++ b/DistroLauncher/DistroLauncher.cpp
@@ -37,6 +37,10 @@ HRESULT InstallDistribution(bool createUser)
return hr;
}
+ if (Ubuntu::CheckInitTasks(g_wslApi, createUser)) {
+ return ERROR_SUCCESS;
+ }
+
// Create a user account.
if (createUser) {
Helpers::PrintMessage(MSG_CREATE_USER_PROMPT);
diff --git a/DistroLauncher/DistroLauncher.vcxproj b/DistroLauncher/DistroLauncher.vcxproj
index 299a19f0..616ff9bc 100644
--- a/DistroLauncher/DistroLauncher.vcxproj
+++ b/DistroLauncher/DistroLauncher.vcxproj
@@ -140,6 +140,7 @@
+
@@ -149,6 +150,9 @@
+
+ $(ProjectDir);%(AdditionalIncludeDirectories)
+
Create
diff --git a/DistroLauncher/DistroLauncher.vcxproj.filters b/DistroLauncher/DistroLauncher.vcxproj.filters
index 4aefa801..990ad3c3 100644
--- a/DistroLauncher/DistroLauncher.vcxproj.filters
+++ b/DistroLauncher/DistroLauncher.vcxproj.filters
@@ -33,6 +33,9 @@
Header Files
+
+ Header Files
+
@@ -50,6 +53,9 @@
Source Files
+
+ Source Files
+
diff --git a/DistroLauncher/Ubuntu/.clang-format b/DistroLauncher/Ubuntu/.clang-format
new file mode 100644
index 00000000..f1776104
--- /dev/null
+++ b/DistroLauncher/Ubuntu/.clang-format
@@ -0,0 +1,11 @@
+BasedOnStyle: Google
+---
+Language: Cpp
+AllowShortBlocksOnASingleLine: Empty
+AllowShortFunctionsOnASingleLine: All
+ColumnLimit: 100
+DerivePointerAlignment: false
+InsertNewlineAtEOF: true
+PointerAlignment: Left
+# We need to keep stdafx.h as the first include of any cpp file.
+SortIncludes: false
diff --git a/DistroLauncher/Ubuntu/InitTasks.cpp b/DistroLauncher/Ubuntu/InitTasks.cpp
new file mode 100644
index 00000000..01095286
--- /dev/null
+++ b/DistroLauncher/Ubuntu/InitTasks.cpp
@@ -0,0 +1,521 @@
+#include
+#include "InitTasks.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace Ubuntu {
+
+namespace {
+// Blocks the current thread until all initialization tasks finish.
+void waitForInitTasks(WslApiLoader& api);
+
+// Enforces the existence of a default WSL user either:
+// - defined in /etc/wsl.conf (which might not be in effect yet)
+// - defined in WSL API/registry
+// - or the lowest non-system account with UID >= 1000 in the NSS passwd database
+// Returns false if a default user couldn't be set.
+bool enforceDefaultUser(WslApiLoader& api);
+} // namespace
+
+bool CheckInitTasks(WslApiLoader& api, bool checkDefaultUser) {
+ waitForInitTasks(api);
+
+ if (!checkDefaultUser) {
+ return true;
+ }
+
+ return enforceDefaultUser(api);
+}
+
+namespace {
+void waitForInitTasks(WslApiLoader& api) {
+ _putws(L"Checking for initialization tasks...\n");
+
+ DWORD exitCode = -1;
+ // Try running cloud-init unconditionally, but avoid printing to console if it doesn't exist.
+ auto hr = api.WslLaunchInteractive(
+ L"function command_not_found_handle() { return 127; }; cloud-init status --wait", FALSE,
+ &exitCode);
+ if (FAILED(hr)) {
+ Helpers::PrintErrorMessage(hr);
+ return;
+ }
+
+ // 127 for command not found, 0 for "success" or 2 for "recoverable error" - sometimes we get that
+ // while status shows "done".
+ // https://cloudinit.readthedocs.io/en/latest/explanation/failure_states.html#cloud-init-error-codes
+ // If that's the case we should inspect the system to check whether we should skip or proceed with
+ // user creation.
+ switch (exitCode) {
+ case 0: {
+ return;
+ }
+ case 127: {
+ _putws(L"INFO: this release doesn't support initialization tasks.\n");
+ return;
+ }
+ case 2: {
+ _putws(L"WARNING: initialization tasks partially succeeded, see below:");
+ break;
+ }
+ default: {
+ wprintf(L"ERROR: initialization failed with exit code: %u\n", exitCode);
+ break;
+ }
+ }
+ // We don't really care if the command below fails, it's just informative.
+ api.WslLaunchInteractive(L"cloud-init status --long", FALSE, &exitCode);
+}
+
+namespace fs = std::filesystem;
+fs::path wslConfPath() {
+ // init-once, lazily.
+ static fs::path etcWslConf =
+ fs::path{L"\\\\wsl.localhost"} / DistributionInfo::Name / "etc\\wsl.conf";
+ return etcWslConf;
+}
+
+bool setDefaultUserViaWslApi(WslApiLoader& api, unsigned long uid) {
+ if (auto hr = api.WslConfigureDistribution(uid, WSL_DISTRIBUTION_FLAGS_DEFAULT); FAILED(hr)) {
+ _putws(L"ERROR: failed to set default user: ");
+ Helpers::PrintErrorMessage(hr);
+ return false;
+ }
+ return true;
+}
+// Groups the pieces of information from a single user entry in the passwd database we care about.
+struct UserEntry {
+ std::string name;
+ ULONG uid = -1;
+ bool hasLogin = false;
+};
+
+// Collects all users found in the NSS passwd database, sorted by UID.
+std::vector getAllUsers(WslApiLoader& api);
+
+// Returns the defaultUser set in /etc/wsl.conf or the empty string if none is set.
+std::string defaultUserInWslConf();
+
+// Converts a multi-byte null-terminated string into a wide string.
+std::wstring str2wide(std::string_view str, UINT codePage = CP_THREAD_ACP);
+
+bool enforceDefaultUser(WslApiLoader& api) try {
+ auto users = getAllUsers(api);
+
+ if (users.empty()) {
+ // unexpectedly nothing to do
+ _putws(L"ERROR: couldn't find any users in NSS database\n");
+ return false;
+ }
+ // 1. We read the default user name from /etc/wsl.conf
+ if (auto name = defaultUserInWslConf(); !name.empty()) {
+ // We still need the UID to be able to call the WSL API.
+ auto found = std::find_if(users.begin(), users.end(),
+ [&name](const UserEntry& u) { return u.name == name; });
+ if (found == users.end()) {
+ // no UID, nothing to do, the system is in a bad state where the user requested in wsl.conf
+ // doesn't exist. We won't fix that.
+ return true;
+ }
+ return setDefaultUserViaWslApi(api, found->uid);
+ }
+ // 2. Check for the Windows registry
+ // This call returns the UID of the current default user, most likely root, unless someone set a
+ // different UID via the registry editor or WSL API, for which case we are done.
+ if (auto uid = DistributionInfo::QueryUid(L""); uid != 0) {
+ return true;
+ }
+
+ // 3. Finally, search for the first non-system user.
+ auto found = std::find_if(users.begin(), users.end(),
+ [](const UserEntry& u) { return u.uid > 999 && u.hasLogin; });
+ if (found != users.end()) {
+ return setDefaultUserViaWslApi(api, found->uid);
+ }
+
+ _putws(L"ERROR: no candidate default user was found\n");
+ return false;
+} catch (const std::exception& err) {
+ _putws(L"ERROR: Unexpected failure when enforcing the default user: ");
+ _putws(str2wide(err.what()).c_str());
+ return false;
+}
+
+// A truly temporary file copy.
+//
+// Invariant: while this object exists, so does the underlying file,
+// being auto-deleted by the OS when this object goes out of scope.
+class TempFileCopy {
+ fs::path p;
+ HANDLE h = nullptr;
+
+ public:
+ ~TempFileCopy() {
+ if (h) {
+ CloseHandle(h);
+ }
+ }
+ const fs::path& path() const { return p; }
+
+ // Creates a temporary copy of the source file configured for auto removal by the OS when this
+ // object goes out of scope under the system's preferred temporary directory.
+ explicit TempFileCopy(const fs::path& source) {
+ // When debugging fs::temp_directory_path() returns a path like LOCALAPPDATA\Temp, but once
+ // packaged it should point inside LOCALAPPDATA\Packages\\...
+ auto path = fs::temp_directory_path();
+
+ wchar_t rawDestination[MAX_PATH] = {'\0'};
+ if (auto uniqueCode = GetTempFileNameW(path.native().c_str(), L"wsl", 0, rawDestination);
+ uniqueCode == 0) {
+ std::string msg{"couldn't create a temporary file inside "};
+ msg += path.string();
+ throw std::system_error{static_cast(GetLastError()), std::system_category(), msg};
+ }
+ fs::path destination{rawDestination};
+
+ // GetTempFileNameA would have created a file for us thus we need to overwrite it.
+ fs::copy_file(source, destination, fs::copy_options::overwrite_existing);
+ fs::permissions(destination, fs::perms::owner_read | fs::perms::owner_write);
+
+ // Allow others to read but not write it, and configure it to be deleted automatically when the
+ // handle is closed (what's the destructor does automatically no matter what).
+ HANDLE file = CreateFileW(
+ rawDestination, GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING,
+ FILE_ATTRIBUTE_TEMPORARY | FILE_ATTRIBUTE_READONLY | FILE_FLAG_DELETE_ON_CLOSE, nullptr);
+ if (file == INVALID_HANDLE_VALUE) {
+ std::string msg{"couldn't open file in auto-delete mode: "};
+ msg += path.string();
+ throw std::system_error{static_cast(GetLastError()), std::system_category(), msg};
+ }
+
+ // fully initializes the object.
+ p = destination;
+ h = file;
+ }
+};
+
+// Reads [user].default from an existing wsl.conf file located at the ini path provided.
+std::string readIniDefaultUser(const fs::path& ini) {
+ // `getconf LOGIN_NAME_MAX` returns 256, but glibc restricts user login names to 32 chars long.
+ static constexpr auto utNameSize = 33; // With room for the NULL terminator just in case.
+ char uname[utNameSize] = {'\0'};
+ auto len =
+ GetPrivateProfileStringA("user", "default", nullptr, uname, utNameSize, ini.string().c_str());
+
+ // GetPrivateProfileStringA set errno to ERROR_FILE_NOT_FOUND if the file
+ // or section or key are not found or if the file is ill-formed. We know the file exists.
+ // Absent user.default entry is a common case, we don't want to spam users for that.
+ // I don't see what other errors that could raise.
+ if (auto e = GetLastError(); len == 0 && e == ERROR_FILE_NOT_FOUND) {
+ return {};
+ }
+
+ return uname;
+}
+
+std::string defaultUserInWslConf() try {
+ auto etcWslConf = wslConfPath();
+ if (!fs::exists(etcWslConf)) {
+ return {};
+ }
+ // Copy /etc/wsl.conf to a temporary local path (guaranteed by the OS to be auto-deleted) from
+ // where GetPrivateProfileStringA can read it.
+ TempFileCopy copy(etcWslConf);
+ return readIniDefaultUser(copy.path());
+
+} catch (std::system_error const& err) {
+ // std::filesystem_error is child of std::system_error
+ std::wcout << L"ERROR: failed to read /etc/wsl.conf: " << err.code() << ": "
+ << str2wide(err.what());
+ return {};
+}
+
+std::wstring str2wide(std::string_view str, UINT codePage) {
+ if (str.empty() || str.size() >= INT_MAX) return {};
+
+ int inputSize = static_cast(str.size());
+ int required = ::MultiByteToWideChar(codePage, 0, str.data(), inputSize, NULL, 0);
+ if (0 == required) return {};
+
+ std::wstring str2;
+ str2.resize(required);
+
+ int converted = ::MultiByteToWideChar(codePage, 0, str.data(), inputSize, &str2[0], required);
+ if (0 == converted) return {};
+
+ return str2;
+}
+
+// A non-interactive WSL process, turned into a class so we don't have to worry about closing
+// the process and pipe's handles.
+class WslProcess {
+ private:
+ HANDLE process_ = nullptr;
+ HANDLE readPipe_ = nullptr;
+ HANDLE writePipe_ = nullptr;
+ std::wstring command_;
+
+ static constexpr auto MaxOutputSize = 4096;
+
+ public:
+ ~WslProcess() {
+ if (process_) {
+ CloseHandle(process_);
+ }
+ if (writePipe_) {
+ CloseHandle(writePipe_);
+ }
+ if (readPipe_) {
+ CloseHandle(readPipe_);
+ }
+ }
+
+ struct Result {
+ std::wstring error;
+ std::size_t exitCode = static_cast(-1);
+ std::string stdOut;
+ };
+
+ // Runs the process via WSL api and wait for timeout milliseconds.
+ Result run(WslApiLoader& api, DWORD timeout);
+
+ explicit WslProcess(std::wstring command_) : command_{command_} {};
+};
+
+// Views a string as a collection of (most likely non-null terminated) substring slices split by the
+// provided delimiter, visited unidirectionally. The backing string is required to outlive this for
+// safe usage. Useful for lazy iteration.
+class SplitView {
+ private:
+ std::string_view parent;
+ char delimiter;
+ std::string_view::const_iterator start;
+
+ public:
+ SplitView(std::string_view str, char delimiter)
+ : parent(str), delimiter(delimiter), start(parent.begin()) {}
+
+ std::optional next() {
+ if (start == parent.end()) {
+ return std::nullopt;
+ }
+
+ auto end = std::find(start, parent.end(), delimiter);
+ std::string_view token = parent.substr(start - parent.begin(), end - start);
+
+ if (end != parent.end()) {
+ start = end + 1;
+ } else {
+ start = end;
+ }
+
+ return token;
+ }
+
+ // This allows plugging the SplitView into std algorithms and range-for loops.
+ auto begin() { return iterator(this); }
+ auto end() { return iterator::sentinel(this); }
+
+ class iterator {
+ private:
+ SplitView* splitView;
+ std::optional current;
+
+ iterator(SplitView* splitView, const std::optional& current)
+ : splitView(splitView), current(current) {}
+
+ public:
+ // Creates a new iterator pointing to the next value of the SplitView, i.e. the
+ // begin-iterator.
+ iterator(SplitView* splitView) : splitView{splitView}, current{splitView->next()} {}
+ // Creates a new sentinel iterator for the provided SplitView, i.e. the end-iterator.
+ static iterator sentinel(SplitView* splitView) { return iterator{splitView, std::nullopt}; }
+
+ // boiler-plate to define a standard-compliant iterator interface.
+ using value_type = std::string_view;
+ using difference_type = std::ptrdiff_t;
+ using pointer = const std::string_view*;
+ using reference = const std::string_view&;
+ using iterator_category = std::input_iterator_tag;
+
+ reference operator*() const { return *current; }
+ pointer operator->() const { return &(*current); }
+
+ iterator& operator++() {
+ current = splitView->next();
+ return *this;
+ }
+
+ iterator operator++(int) {
+ iterator temp = *this;
+ ++(*this);
+ return temp;
+ }
+
+ friend bool operator==(const iterator& a, const iterator& b) {
+ return a.splitView == b.splitView && a.current == b.current;
+ }
+
+ friend bool operator!=(const iterator& a, const iterator& b) { return !(a == b); }
+ };
+};
+
+// Parses a string modelling a line of passwd into a UserEntry object.
+// We only care about login name, UID and the login shell, although the lines should have 7 fields:
+// ^NAME:ENCRYPTION:UID:...3 fields...:SHELL\n$
+// Returns std::nullopt on parse failure, the exact error for ill-formed lines is not needed.
+std::optional userEntryFromString(std::string_view line);
+
+// Assuming UnaryOperation returns a std::optional, applies it to each element of the range
+// [first,last[ and stores the results that actually hold a value into the output range.
+// It's like std::transform, but skips the results of UnaryOperation that returns std::nullopt,
+// thus the output range size is going to be smaller than or equal to the input range size.
+template
+OutputIt transform_maybe(InputIt first, InputIt last, OutputIt d_first, UnaryOperation unary_op) {
+ while (first != last) {
+ auto&& in = *first;
+ std::optional maybe = unary_op(in);
+ if (maybe) {
+ *d_first++ = *maybe;
+ }
+ ++first;
+ }
+ return d_first;
+}
+
+std::vector getAllUsers(WslApiLoader& api) {
+ WslProcess getent{L"getent passwd"};
+ auto [error, exitCode, output] = getent.run(api, 10'000);
+ if (!error.empty()) {
+ _putws(L"failed to read passwd database: ");
+ _putws(error.c_str());
+ if (exitCode != 0) {
+ wprintf(L"%lld", exitCode);
+ }
+ return {};
+ }
+
+ if (output.empty()) {
+ return {};
+ }
+
+ std::vector users;
+ // Where the boilerplate pays-off: splits the getent output by lines
+ SplitView lines{output, '\n'};
+ // and store the parsed results in the users vector.
+ // NOTE about ill-formed lines in passwd: this algorithm just skips them.
+ // Broken lines in /etc/passwd won't prevent the effects of the good lines.
+ // getent itself reports errors for broken lines but still output the good ones.
+ // The system behaves as if they don't exist. So we can ignore them as well.
+ transform_maybe(lines.begin(), lines.end(), std::back_inserter(users), userEntryFromString);
+ // Finally sort that vector by UID.
+ std::sort(users.begin(), users.end(),
+ [](const UserEntry& a, const UserEntry& b) { return a.uid < b.uid; });
+ return users;
+}
+
+std::optional userEntryFromString(std::string_view line) {
+ SplitView fields{line, ':'};
+ // Field 0: name
+ auto name = fields.next();
+ if (!name || name->empty()) {
+ return std::nullopt;
+ }
+ // Field 1: encryption flag, unused.
+ auto unused = fields.next();
+ if (!unused) {
+ return std::nullopt;
+ }
+ // Field 2: UID
+ auto u = fields.next();
+ if (!u) {
+ return std::nullopt;
+ }
+ ULONG uid = -1;
+ auto ud = std::from_chars(u->data(), u->data() + u->length(), uid);
+ if (ud.ec != std::errc{}) {
+ // cannot convert UID to an integer
+ return std::nullopt;
+ }
+ // Fields 3, 4 and 5: unused in this context, but still must be checked, otherwise the line is
+ // ill-formed.
+ for (int i = 0; i < 3; ++i) {
+ if (unused = fields.next(); !unused) {
+ return std::nullopt;
+ }
+ }
+ // Field 6: the login shell.
+ auto shell = fields.next();
+ if (!shell || shell->empty()) {
+ return std::nullopt;
+ }
+
+ // For this particular case it seems that an exclusion list is easier than a
+ // positive list of what shells are valid as there are more valid shell choices (sh, bash, csh,
+ // dash, ksh, tcsh, zsh, fish, ...).
+ bool hasLogin =
+ (shell->find("/sync") == std::string::npos && shell->find("/nologin") == std::string::npos &&
+ shell->find("/false") == std::string::npos);
+
+ return UserEntry{std::string{name->begin(), name->end()}, uid, hasLogin};
+}
+
+WslProcess::Result WslProcess::run(WslApiLoader& api, DWORD timeout) {
+ // Create a pipe to read the output of the launched process.
+ HANDLE read, write, process;
+ SECURITY_ATTRIBUTES sa{sizeof(sa), nullptr, true};
+ ULONG uid = -1;
+ if (CreatePipe(&read, &write, &sa, 0) == FALSE) {
+ return {L"failed to create the stdio pipe"};
+ }
+ // We have to remember to close the pipe handles.
+ readPipe_ = read;
+ writePipe_ = write;
+
+ DWORD exitCode = -1;
+ auto hr = api.WslLaunch(command_.c_str(), FALSE, GetStdHandle(STD_INPUT_HANDLE), writePipe_,
+ GetStdHandle(STD_ERROR_HANDLE), &process);
+ if (FAILED(hr)) {
+ return {L"failed to launch process"};
+ }
+ // Also need to remember to close the process handle.
+ process_ = process;
+
+ if (auto wait = WaitForSingleObject(process_, timeout); wait == WAIT_TIMEOUT) {
+ return {L"terminated due timed out"};
+ }
+
+ exitCode = -1;
+ if ((GetExitCodeProcess(process_, &exitCode) == false) || (exitCode != 0)) {
+ return {L"exited with error", exitCode};
+ }
+
+ // Check how many bytes we need to allocate to pump all contents out the pipe.
+ DWORD unreadBytes;
+ if (FALSE == PeekNamedPipe(readPipe_, 0, 0, 0, &unreadBytes, 0) || unreadBytes == 0) {
+ return {L"could not read the process output", 0};
+ }
+ std::size_t size = 1ULL + unreadBytes;
+ if (size > MaxOutputSize) {
+ return {L"process output is too big", 0};
+ }
+ std::string contents(size, '\0');
+
+ // Finally we do the read.
+ DWORD readCount = 0, avail = 0;
+ if (FALSE == ReadFile(readPipe_, contents.data(), unreadBytes, &readCount, nullptr) ||
+ read == 0) {
+ return {L"could not read the process output", 0};
+ }
+
+ return {{}, 0, contents};
+}
+
+} // namespace
+} // namespace Ubuntu
diff --git a/DistroLauncher/Ubuntu/InitTasks.h b/DistroLauncher/Ubuntu/InitTasks.h
new file mode 100644
index 00000000..71ca2234
--- /dev/null
+++ b/DistroLauncher/Ubuntu/InitTasks.h
@@ -0,0 +1,8 @@
+#pragma once
+namespace Ubuntu
+{
+ // Returns true if system initialization tasks are complete.
+ // If [checkDefaultUser] is true, we consider creating the default user part of such tasks.
+ bool CheckInitTasks(WslApiLoader& api, bool checkDefaultUser);
+};
+
diff --git a/DistroLauncher/stdafx.h b/DistroLauncher/stdafx.h
index 54123d4b..bd5117c4 100644
--- a/DistroLauncher/stdafx.h
+++ b/DistroLauncher/stdafx.h
@@ -32,3 +32,6 @@
// Message strings compiled from .MC file.
#include "messages.h"
+
+// Ubuntu extensions
+#include "Ubuntu/InitTasks.h"
diff --git a/e2e/go.work b/e2e/go.work
index 5ee38570..a6385f3f 100644
--- a/e2e/go.work
+++ b/e2e/go.work
@@ -1,3 +1,5 @@
-go 1.21.5
+go 1.22.0
+
+toolchain go1.22.5
use ./launchertester
diff --git a/e2e/go.work.sum b/e2e/go.work.sum
new file mode 100644
index 00000000..594145dc
--- /dev/null
+++ b/e2e/go.work.sum
@@ -0,0 +1,6 @@
+github.com/0xrawsec/golang-utils v1.3.2/go.mod h1:m7AzHXgdSAkFCD9tWWsApxNVxMlyy7anpPVOyT/yM7E=
+github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
+github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
+github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
+github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
+gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
diff --git a/e2e/launchertester/basic_setup_test.go b/e2e/launchertester/basic_setup_test.go
index 2d11772a..e310972c 100644
--- a/e2e/launchertester/basic_setup_test.go
+++ b/e2e/launchertester/basic_setup_test.go
@@ -3,10 +3,16 @@ package launchertester
import (
"context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
"testing"
"time"
"github.com/stretchr/testify/require"
+ "github.com/ubuntu/gowsl"
)
// TestBasicSetup runs a battery of assertions after installing with the distro launcher.
@@ -18,20 +24,145 @@ func TestBasicSetup(t *testing.T) {
// TODO: try to inject user/password to stdin to avoid --root arg.
out, err := launcherCommand(ctx, "install", "--root").CombinedOutput() // Installing as root to avoid Stdin
require.NoErrorf(t, err, "Unexpected error installing: %s\n%v", out, err)
- // TODO: check with Carlos if this is necessary
- require.NotEmpty(t, out, "Failed to install the distro: No output produced.")
testCases := map[string]func(t *testing.T){
- // TODO: Re-enable those tests once the latest wsl-setup with distro patching land.
- // "SystemdEnabled": testSystemdEnabled,
- // "SystemdUnits": testSystemdUnits,
- // "CorrectUpgradePolicy": testCorrectUpgradePolicy,
- // "UpgradePolicyIdempotent": testUpgradePolicyIdempotent,
- "InteropIsEnabled": testInteropIsEnabled,
- "HelpFlag": testHelpFlag,
+ "SystemdEnabled": testSystemdEnabled,
+ "SystemdUnits": testSystemdUnits,
+ "CorrectUpgradePolicy": testCorrectUpgradePolicy,
+ "UpgradePolicyIdempotent": testUpgradePolicyIdempotent,
+ "InteropIsEnabled": testInteropIsEnabled,
+ "HelpFlag": testHelpFlag,
}
for name, tc := range testCases {
t.Run(name, tc)
}
}
+
+// TestSetupWithCloudInit runs a battery of assertions after installing with the distro launcher and cloud-init.
+func TestSetupWithCloudInit(t *testing.T) {
+ testCases := map[string]struct {
+ install_root bool
+ withRegistryUser string
+ wantUser string
+ wantFile string
+ }{
+ "With default user in conf": {wantUser: "testuser", wantFile: "/etc/with_default_user.done"},
+ "With default user in registry": {withRegistryUser: "testuser", wantUser: "testuser", wantFile: "/etc/with_default_user.done"},
+ "With default user in both": {withRegistryUser: "anotheruser", wantUser: "testuser"},
+ "With default user in none": {wantUser: "testuser", wantFile: "/home/testuser/with_default_user.done"},
+
+ "With only remote users": {wantUser: "testmail"},
+ "With broken passwd file": {wantUser: "testmail"},
+ "Without checking user": {install_root: true, wantUser: "root", wantFile: "/home/testuser/with_default_user.done"},
+ }
+
+ home, err := os.UserHomeDir()
+ require.NoError(t, err, "Setup: Cannot get user home directory")
+ cloudinitdir := filepath.Join(home, ".cloud-init")
+ backupDir := filepath.Join(t.TempDir(), "cloud-init-backup")
+ if _, err = os.Stat(cloudinitdir); err == nil {
+ require.NoError(t, os.Rename(cloudinitdir, backupDir), "Failed to backup cloud-init directory")
+ }
+ require.NoError(t, os.MkdirAll(cloudinitdir, 0755), "Setup: Cannot create cloud-init directory")
+ t.Cleanup(func() {
+ if err := os.RemoveAll(cloudinitdir); err != nil {
+ t.Logf("Setup: Failed to remove user-data file after test: %v", err)
+ }
+ if err = os.Rename(backupDir, cloudinitdir); err != nil {
+ t.Logf("Setup: Failed to restore cloud-init directory after test: %v", err)
+ }
+ })
+
+ for name, tc := range testCases {
+ t.Run(name, func(t *testing.T) {
+ wslSetup(t)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
+ defer cancel()
+
+ userdata, err := os.ReadFile(TestFixturePath(t))
+ require.NoError(t, err, "Setup: Cannot read test user-data file")
+ err = os.WriteFile(filepath.Join(cloudinitdir, "default.user-data"), userdata, 0755)
+ require.NoError(t, err, "Setup: Cannot write user-data file to the right location")
+
+ var args []string
+ if tc.install_root {
+ args = append(args, "--root")
+ }
+
+ registrySet := make(chan error)
+ if len(tc.withRegistryUser) == 0 {
+ close(registrySet)
+ } else {
+ go func() {
+ defer close(registrySet)
+
+ d := gowsl.NewDistro(ctx, *distroName)
+ for {
+ select {
+ case <-ctx.Done():
+ registrySet <- ctx.Err()
+ return
+ default:
+ }
+
+ state, err := d.State()
+ if err != nil {
+ // WSL is not ready for concurrent access. Errors are very likely when registering or unregistering.
+ t.Logf("Setup: Couldn't to get distro state this time: %v", err)
+ time.Sleep(10 * time.Second)
+ continue
+ }
+
+ if state == gowsl.Uninstalling {
+ registrySet <- fmt.Errorf("Setup: too late to set the registry: distro is uninstalling")
+ return
+ }
+
+ if state == gowsl.NonRegistered || state == gowsl.Installing {
+ t.Logf("Setup: Waiting for distro to be registered to set default user via registry")
+ time.Sleep(10 * time.Second)
+ continue
+ }
+ // if running or stopped
+ id := wslCommand(ctx, "id", "-u", tc.withRegistryUser)
+ out, err := id.CombinedOutput()
+ if err != nil {
+ t.Logf("Setup: Failed to get uid for %s: %v", tc.withRegistryUser, err)
+ time.Sleep(300 * time.Millisecond)
+ continue
+ }
+ uid, err := strconv.Atoi(strings.TrimSpace(string(out)))
+ if err != nil {
+ t.Logf("Setup: Failed to parse %s: %v", out, err)
+ time.Sleep(300 * time.Millisecond)
+ continue
+ }
+ // We use cloud-init to create the user with cloud-config data we control, so we expect it to be in the range of normal users.
+ if uid < 1000 || uid > 60000 {
+ registrySet <- fmt.Errorf("Setup: unexpected uid: %d", uid)
+ return
+ }
+ registrySet <- d.DefaultUID(uint32(uid))
+ return
+ }
+ }()
+ }
+
+ out, err := launcherCommand(ctx, "install", args...).CombinedOutput() // Using the "install" command to avoid the shell after installation.
+ require.NoErrorf(t, err, "Unexpected error installing: %s\n%v", out, err)
+
+ // Seems out of order but setting the registry is concurrent with the installation, hopefully it will be set before the
+ // launcher checks for the default user. Either way the user assertion in the end of this test case will work as exoected.
+ require.NoError(t, <-registrySet, "Setup: Failed to set default user via GoWSL/registry")
+
+ testSystemdEnabled(t)
+ testInteropIsEnabled(t)
+ if len(tc.wantFile) > 0 {
+ testFileExists(t, tc.wantFile)
+ }
+ testDefaultUser(t, tc.wantUser)
+ })
+ }
+}
diff --git a/e2e/launchertester/default_experience_test.go b/e2e/launchertester/default_experience_test.go
deleted file mode 100644
index 435fd268..00000000
--- a/e2e/launchertester/default_experience_test.go
+++ /dev/null
@@ -1,129 +0,0 @@
-package launchertester
-
-import (
- "context"
- "errors"
- "os/exec"
- "strings"
- "testing"
- "time"
-
- "github.com/stretchr/testify/require"
-)
-
-// TestDefaultExperience tests the experience that most users have:
-// opening WSL from the store or from the Start menu.
-func TestDefaultExperience(t *testing.T) {
- t.Skip("Skipped: fails in Azure") // TODO: Fix
- wslSetup(t)
-
- ctx, cancel := context.WithCancel(context.Background())
- defer cancel()
-
- // TODO: redirect powershell.exe output to a file and read from it.
- commandText := []string{"-Command", "wt.exe", "--window", "0", "-d", ".",
- "powershell.exe", "-noExit", *launcherName, "--hide-console"}
- cmd := exec.CommandContext(ctx, "powershell", commandText...)
-
- err := cmd.Start()
- require.NoErrorf(t, err, "Failed to start new WSL command")
-
- waitStateTransition(t, "DistroNotFound", "Installing")
- waitStateTransition(t, "Installing", "Running")
- waitForInstaller(t)
-
- state := distroState(t)
- require.Equal(t, "Running", state, "Unexpected state for after installation")
-
- time.Sleep(15 * time.Second) // If tty did not start, it should stop after 8 seconds
- state = distroState(t)
- require.Equal(t, "Running", state, "Distro should still be running after 15 seconds, because a shell should still be open")
-
- cancel()
- err = cmd.Wait()
- require.NoError(t, err, "Unexpected error after finishing command")
-
- testCases := map[string]func(t *testing.T){
- "UserNotRoot": testUserNotRoot,
- "SystemdEnabled": testSystemdEnabled,
- "SystemdUnits": testSystemdUnits,
- "CorrectUpgradePolicy": testCorrectUpgradePolicy,
- "UpgradePolicyIdempotent": testUpgradePolicyIdempotent,
- "InteropIsEnabled": testInteropIsEnabled,
- }
-
- for name, tc := range testCases {
- t.Run(name, tc)
- }
-}
-
-// waitStateTransition waits until the current state (fromState) transitions into toState.
-// Fails if any other state is reached.
-// Fails if the state cannot be parsed.
-func waitStateTransition(t *testing.T, fromState string, toState string) {
- t.Helper()
-
- t.Logf("Awaiting state transition: %s -> %s", fromState, toState)
- state := distroState(t)
- require.Containsf(t, []string{fromState, toState}, state, "In transition from '%s' to '%s': Unexpected state '%s'", fromState, toState, state)
-
- ticker := time.NewTicker(1 * time.Second)
- defer ticker.Stop()
-
- timeout := time.After(60 * time.Second)
-
- for {
- select {
- case <-timeout:
- t.Fatalf("Didn't reach expected state in time. Last state: %s", state)
- case <-ticker.C:
- state = distroState(t)
- if state == fromState {
- continue
- }
- }
- break
- }
-
- require.Equalf(t, toState, state, "After transition '%s' -> '%s': Unexpected final state '%s'", fromState, toState, state)
-}
-
-// waitForInstaller waits until the subiquity server log indicates that the installation
-// has finsihed.
-// Considerations:
-// - The server may still run for a small amount of time after the log
-// says it has finished. Experimentally, it seems to be less than a second.
-// - The State of the distro must be either Running or Stopped when called (i.e. installation must have been started).
-func waitForInstaller(t *testing.T) {
- t.Helper()
-
- require.Contains(t, []string{"Running", "Stopped"}, distroState(t))
- t.Logf("Waiting for installer to finish")
- defer t.Logf("Installation finished")
-
- ticker := time.NewTicker(time.Second)
- defer ticker.Stop()
-
- timeout := time.After(2 * time.Minute)
-
- for {
- select {
- case <-timeout:
- t.Fatal("Timed out waiting for installer to finish")
- case <-ticker.C:
- out, err := wslCommand(context.Background(), "cat", serverLogPath).CombinedOutput()
-
- var target *exec.ExitError
- if errors.As(err, &target) {
- require.Equal(t, target.ProcessState.ExitCode(), 1, "Unexpected error reading subiquity server log: %s", out)
- continue // Log not yet available
- }
- require.NoErrorf(t, err, "Unexpected error reading subiquity server log: %s", out)
-
- if strings.Contains(string(out), "finish: subiquity/SetupShutdown/shutdown: SUCCESS") {
- time.Sleep(time.Second) // Offering the server some time to finish
- return
- }
- }
- }
-}
diff --git a/e2e/launchertester/go.mod b/e2e/launchertester/go.mod
index eb5b52c8..298ae6ed 100644
--- a/e2e/launchertester/go.mod
+++ b/e2e/launchertester/go.mod
@@ -1,14 +1,21 @@
module github.com/ubuntu/wsl/e2e/launchertester
-go 1.21.5
+go 1.22.0
+
+toolchain go1.22.3
require (
- github.com/stretchr/testify v1.8.1
+ github.com/stretchr/testify v1.9.0
gopkg.in/ini.v1 v1.67.0
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/google/uuid v1.6.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
+ github.com/sirupsen/logrus v1.9.0 // indirect
+ github.com/ubuntu/decorate v0.0.0-20230125165522-2d5b0a9bb117 // indirect
+ github.com/ubuntu/gowsl v0.0.0-20240621022151-0ca2385d9153 // indirect
+ golang.org/x/sys v0.20.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
diff --git a/e2e/launchertester/go.sum b/e2e/launchertester/go.sum
index 2f9868ae..c8af9cdc 100644
--- a/e2e/launchertester/go.sum
+++ b/e2e/launchertester/go.sum
@@ -1,17 +1,31 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0=
+github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/ubuntu/decorate v0.0.0-20230125165522-2d5b0a9bb117 h1:XQpsQG5lqRJlx4mUVHcJvyyc1rdTI9nHvwrdfcuy8aM=
+github.com/ubuntu/decorate v0.0.0-20230125165522-2d5b0a9bb117/go.mod h1:mx0TjbqsaDD9DUT5gA1s3hw47U6RIbbIBfvGzR85K0g=
+github.com/ubuntu/gowsl v0.0.0-20240621022151-0ca2385d9153 h1:WEZ1t/PjXdeyyxTFQwS7SO2wKC8czBf2R6/vcWonD0g=
+github.com/ubuntu/gowsl v0.0.0-20240621022151-0ca2385d9153/go.mod h1:t74qqYMKvTnMyTHEDhVlbouDpk5BaIk1TyejRtyTSQ8=
+golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
+golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/e2e/launchertester/test_cases.go b/e2e/launchertester/test_cases.go
index ac40342c..a733d894 100644
--- a/e2e/launchertester/test_cases.go
+++ b/e2e/launchertester/test_cases.go
@@ -5,6 +5,7 @@ import (
"bytes"
"context"
"os/exec"
+ "regexp"
"strings"
"testing"
"time"
@@ -14,23 +15,19 @@ import (
"gopkg.in/ini.v1"
)
-// testUserNotRoot ensures the default user is not root.
-func testUserNotRoot(t *testing.T) { //nolint: thelper, this is a test
- t.Parallel()
-
+// testDefaultUser ensures the default user matches the expected.
+func testDefaultUser(t *testing.T, expected string) { //nolint: thelper, this is a test
ctx, cancel := context.WithTimeout(context.Background(), systemdBootTimeout)
defer cancel()
out, err := wslCommand(ctx, "whoami").CombinedOutput()
require.NoErrorf(t, err, "Unexpected failure executing whoami: %s", out)
-
- require.NotContains(t, string(out), "root", "Default user should not be root.")
+ got := strings.TrimSpace(string(out))
+ require.Equal(t, expected, got, "Default user should be %s, got %s", expected, got)
}
// testSystemdEnabled ensures systemd was enabled.
func testSystemdEnabled(t *testing.T) { //nolint: thelper, this is a test
- t.Parallel()
-
ctx, cancel := context.WithTimeout(context.Background(), systemdBootTimeout)
defer cancel()
@@ -90,11 +87,10 @@ func testSystemdUnits(t *testing.T) { //nolint: thelper, this is a test
// testCorrectUpgradePolicy ensures upgrade policy matches the one expected for the app.
func testCorrectUpgradePolicy(t *testing.T) { //nolint: thelper, this is a test
- t.Parallel()
-
/* Ubuntu always upgrade to next lts */
wantPolicy := "lts"
- if strings.HasPrefix(*distroName, "Ubuntu") && strings.HasSuffix(*distroName, "LTS") {
+ ltsRegex := regexp.MustCompile(`^Ubuntu-\d{2}\.\d{2}$`) // Ubuntu-WX.YZ
+ if ltsRegex.MatchString(*distroName) {
wantPolicy = "never"
} else if *distroName != "Ubuntu" {
// Preview and Dev
@@ -146,7 +142,6 @@ func testUpgradePolicyIdempotent(t *testing.T) { //nolint: thelper, this is a te
// testInteropIsEnabled ensures interop works fine.
// See related issue: https://github.com/ubuntu/WSL/issues/334
func testInteropIsEnabled(t *testing.T) { //nolint: thelper, this is a test
- t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), systemdBootTimeout)
defer cancel()
@@ -175,3 +170,11 @@ func testHelpFlag(t *testing.T) {
require.NoError(t, err, "could not run '%s help': %v, %s", *launcherName, err, out)
require.Contains(t, string(out), usageFirstLine, "help command should have been picked up by the launcher")
}
+
+func testFileExists(t *testing.T, linuxPath string) {
+ ctx, cancel := context.WithTimeout(context.Background(), systemdBootTimeout)
+ defer cancel()
+
+ out, err := launcherCommand(ctx, "run", "test", "-e", linuxPath).CombinedOutput()
+ require.NoError(t, err, "Unexpected error checking file existence: %s", out)
+}
diff --git a/e2e/launchertester/test_utils.go b/e2e/launchertester/test_utils.go
index 2e66e799..6657b1cd 100644
--- a/e2e/launchertester/test_utils.go
+++ b/e2e/launchertester/test_utils.go
@@ -6,6 +6,7 @@ import (
"context"
"flag"
"os/exec"
+ "path/filepath"
"regexp"
"strings"
"testing"
@@ -14,12 +15,6 @@ import (
"github.com/stretchr/testify/require"
)
-const (
- serverLogPath = "/var/log/installer/systemsetup-server-debug.log"
- clientLogPath = "ubuntu-desktop-installer/packages/ubuntu_wsl_setup/build/windows/runner/Debug/.ubuntu_wsl_setup.exe/ubuntu_wsl_setup.exe.log"
- subiquityAnswerFile = "/var/log/prefill-system-setup.yaml"
-)
-
// These timeouts have been decided on experimentally, according to the time each action
// could reasonably need on an Azure runner.
const (
@@ -77,7 +72,7 @@ func checkValidTestbed(t *testing.T) {
func terminateDistro(t *testing.T) {
t.Helper()
out, err := exec.Command("wsl.exe", "--terminate", *distroName).CombinedOutput()
- require.NoError(t, err, "Failed to shut down WLS: %s", out)
+ require.NoError(t, err, "Failed to shut down WSL: %s", out)
}
// distroState parses the output of "wsl -l -v" to find the state of the current distro.
@@ -122,3 +117,24 @@ func distroState(t *testing.T) string {
return distroNotFoundMsg
}
+
+// TestFixturePath returns the path of the dir or file for storing fixture specific to the subtest name.
+func TestFixturePath(t *testing.T) string {
+ t.Helper()
+
+ // Ensures that only the name of the parent test is used.
+ familyName, subtestName, _ := strings.Cut(t.Name(), "/")
+
+ return filepath.Join("testdata", familyName, normalizeName(t, subtestName))
+}
+
+// normalizeName returns a path from name with illegal Windows
+// characters replaced or removed.
+func normalizeName(t *testing.T, name string) string {
+ t.Helper()
+
+ name = strings.ReplaceAll(name, `\`, "_")
+ name = strings.ReplaceAll(name, ":", "")
+ name = strings.ToLower(name)
+ return name
+}
diff --git a/e2e/launchertester/testdata/TestSetupWithCloudInit/with_broken_passwd_file b/e2e/launchertester/testdata/TestSetupWithCloudInit/with_broken_passwd_file
new file mode 100644
index 00000000..7abf8bfc
--- /dev/null
+++ b/e2e/launchertester/testdata/TestSetupWithCloudInit/with_broken_passwd_file
@@ -0,0 +1,35 @@
+#cloud-config
+locale: pt_BR.UTF-8
+
+# This not become the default user because the shell is not a login shell.
+users:
+ - gecos: Invalid user
+ name: invalid
+ groups: users, admin
+ lock_passwd: true
+ shell: /bin/false
+
+# This should become the default user in the end.
+runcmd:
+ - useradd --uid 71000 --create-home --shell /bin/sh --password '*' testmail
+
+# We're using cloud-init as double-agent, doing it's regular job but also breaking the system for tests.
+# Here we'll have a /etc/passwd file with:
+# - ubuntu user with UID 2000 but nologin
+# - u user with UID 2001 but nologin and an extra non-sense metadata field
+# - someuser with just a name and nothing else.
+# - UID 1001 without a name
+# - break_the_world user with UID 1002 missing fields (home and shell are not present)
+# - lxd user with UID 998 which shouldn't interfere with the results
+# No one of those lines are suitable for being the default user
+write_files:
+ - path: /etc/passwd
+ append: true
+ defer: true # So we don't interfere with the runcmd above
+ content: |
+ ubuntu:x:2000:2000:Ubuntu:/home/ubuntu:/usr/bin/nologin
+ u:x:2001:2001:Ubuntu:/home/ubuntu:/usr/bin/nologin:metadata
+ someuser:
+ :x:1001::Another User:/home/anotheruser:/bin/bash
+ break_the_world::1002:1003:Break The World
+ lxd:x:998:998:LXD User:/home/lxd:/bin/bash
diff --git a/e2e/launchertester/testdata/TestSetupWithCloudInit/with_default_user_in_both b/e2e/launchertester/testdata/TestSetupWithCloudInit/with_default_user_in_both
new file mode 100644
index 00000000..19f88dc3
--- /dev/null
+++ b/e2e/launchertester/testdata/TestSetupWithCloudInit/with_default_user_in_both
@@ -0,0 +1,30 @@
+#cloud-config
+apt:
+ preserve_sources_list: false
+ disable_suites:
+ - backports
+ primary:
+ - arches:
+ - default
+ uri: http://br.archive.ubuntu.com/ubuntu
+locale: pt_BR.UTF-8
+users:
+ - name: nontestuser
+ groups: sudo
+ shell: /bin/bash
+ sudo: ALL=(ALL) NOPASSWD:ALL
+ - name: anotheruser
+ groups: sudo
+ shell: /bin/bash
+ sudo: ALL=(ALL) NOPASSWD:ALL
+ - name: testuser
+ groups: sudo
+ shell: /bin/bash
+ sudo: ALL=(ALL) NOPASSWD:ALL
+
+write_files:
+ - path: /etc/wsl.conf
+ append: true
+ content: |
+ [user]
+ default=testuser
diff --git a/e2e/launchertester/testdata/TestSetupWithCloudInit/with_default_user_in_conf b/e2e/launchertester/testdata/TestSetupWithCloudInit/with_default_user_in_conf
new file mode 100644
index 00000000..9128e70f
--- /dev/null
+++ b/e2e/launchertester/testdata/TestSetupWithCloudInit/with_default_user_in_conf
@@ -0,0 +1,20 @@
+#cloud-config
+users:
+ - name: nontestuser
+ groups: sudo
+ shell: /bin/bash
+ sudo: ALL=(ALL) NOPASSWD:ALL
+ - name: testuser
+ groups: sudo
+ shell: /bin/bash
+ sudo: ALL=(ALL) NOPASSWD:ALL
+
+write_files:
+ - path: /etc/wsl.conf
+ append: true
+ content: "[user]\r\ndefault=testuser\r\n"
+
+runcmd:
+ # Make sure to take longer than WSL boot timeout (10s).
+ - sleep 30s
+ - touch /etc/with_default_user.done
diff --git a/e2e/launchertester/testdata/TestSetupWithCloudInit/with_default_user_in_none b/e2e/launchertester/testdata/TestSetupWithCloudInit/with_default_user_in_none
new file mode 100644
index 00000000..634f459d
--- /dev/null
+++ b/e2e/launchertester/testdata/TestSetupWithCloudInit/with_default_user_in_none
@@ -0,0 +1,13 @@
+#cloud-config
+users:
+ - name: testuser
+ groups: sudo
+ shell: /bin/bash
+ sudo: ALL=(ALL) NOPASSWD:ALL
+ - name: nontestuser
+ groups: sudo
+ shell: /bin/bash
+ sudo: ALL=(ALL) NOPASSWD:ALL
+
+runcmd:
+ - touch /home/testuser/with_default_user.done
diff --git a/e2e/launchertester/testdata/TestSetupWithCloudInit/with_default_user_in_registry b/e2e/launchertester/testdata/TestSetupWithCloudInit/with_default_user_in_registry
new file mode 100644
index 00000000..2841acf0
--- /dev/null
+++ b/e2e/launchertester/testdata/TestSetupWithCloudInit/with_default_user_in_registry
@@ -0,0 +1,17 @@
+#cloud-config
+users:
+ - name: nontestuser
+ groups: sudo
+ shell: /bin/bash
+ sudo: ALL=(ALL) NOPASSWD:ALL
+ - name: testuser
+ groups: sudo
+ shell: /bin/bash
+ sudo: ALL=(ALL) NOPASSWD:ALL
+ - name: anotheruser
+ groups: sudo
+ shell: /bin/bash
+ sudo: ALL=(ALL) NOPASSWD:ALL
+
+runcmd:
+ - touch /etc/with_default_user.done
diff --git a/e2e/launchertester/testdata/TestSetupWithCloudInit/with_only_remote_users b/e2e/launchertester/testdata/TestSetupWithCloudInit/with_only_remote_users
new file mode 100644
index 00000000..62f1b978
--- /dev/null
+++ b/e2e/launchertester/testdata/TestSetupWithCloudInit/with_only_remote_users
@@ -0,0 +1,16 @@
+#cloud-config
+apt:
+ preserve_sources_list: false
+ disable_suites:
+ - backports
+ primary:
+ - arches:
+ - default
+ uri: http://br.archive.ubuntu.com/ubuntu
+locale: pt_BR.UTF-8
+users: []
+# Here we're using cloud-init to pretend that the system has remote accounts (LDAP for example)
+# Hopefully the system won't have any non-system account with a lower UID. :)
+runcmd:
+ - useradd --uid 73101 --create-home --shell /bin/bash --password '*' somemail
+ - useradd --uid 71000 --create-home --shell /bin/bash --password '*' testmail
diff --git a/e2e/launchertester/testdata/TestSetupWithCloudInit/without_checking_user b/e2e/launchertester/testdata/TestSetupWithCloudInit/without_checking_user
new file mode 100644
index 00000000..fff82286
--- /dev/null
+++ b/e2e/launchertester/testdata/TestSetupWithCloudInit/without_checking_user
@@ -0,0 +1,20 @@
+#cloud-config
+users:
+ - name: nontestuser
+ groups: sudo
+ shell: /bin/bash
+ sudo: ALL=(ALL) NOPASSWD:ALL
+ - name: testuser
+ groups: sudo
+ shell: /bin/bash
+ sudo: ALL=(ALL) NOPASSWD:ALL
+
+write_files:
+ - path: /etc/wsl.conf
+ append: true
+ content: |
+ [user]
+ default=testuser
+
+runcmd:
+ - touch /home/testuser/with_default_user.done