CLI: Add initial support for image tag command (#14416)

* Initial support for image tag command

* Init test

* Init e2e test

* Adde E2E tests

* Added more tests

* Added more tests

* Resolve copilot comment

* Clang format

* Clang format

* Fix build

* Update parser

* Update loc

* Fix test

* Added more tests

* Clang format

* Loc

* Addressed comments
This commit is contained in:
AmirMS
2026-04-09 15:53:04 -07:00
committed by GitHub
parent 74c5387d99
commit f503b0666e
17 changed files with 368 additions and 11 deletions

View File

@@ -2126,6 +2126,10 @@ For privacy information about this product please visit https://aka.ms/privacy.<
<value>No WSLC session found in '{}'</value>
<comment>{FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated</comment>
</data>
<data name="MessageWslcTagImageInvalidFormat" xml:space="preserve">
<value>Invalid image tag format: '{}'. Expected format is 'name:tag'</value>
<comment>{FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated</comment>
</data>
<data name="WSLCCLI_RootCommandDesc" xml:space="preserve">
<value>WSLC is the Windows Subsystem for Linux Container CLI tool.</value>
<comment>{Locked="WSLC"}Product names should not be translated</comment>
@@ -2259,6 +2263,12 @@ For privacy information about this product please visit https://aka.ms/privacy.<
<data name="WSLCCLI_ImageSaveLongDesc" xml:space="preserve">
<value>Saves images.</value>
</data>
<data name="WSLCCLI_ImageTagDesc" xml:space="preserve">
<value>Tag an image.</value>
</data>
<data name="WSLCCLI_ImageTagLongDesc" xml:space="preserve">
<value>Tags an image.</value>
</data>
<data name="WSLCCLI_SessionCommandDesc" xml:space="preserve">
<value>Manage sessions.</value>
</data>
@@ -2403,9 +2413,15 @@ On first run, creates the file with all settings commented out at their defaults
<value>Signal to send (default: {})</value>
<comment>{FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated</comment>
</data>
<data name="WSLCCLI_SourceArgDescription" xml:space="preserve">
<value>Current or existing image reference in the image-name[:tag] format</value>
</data>
<data name="WSLCCLI_TagArgDescription" xml:space="preserve">
<value>Tag for the built image</value>
</data>
<data name="WSLCCLI_TargetArgDescription" xml:space="preserve">
<value>New image reference in the image-name[:tag] format</value>
</data>
<data name="WSLCCLI_TimeArgDescription" xml:space="preserve">
<value>Time in seconds to wait before executing (default 5)</value>
</data>

View File

@@ -1243,7 +1243,7 @@ std::tuple<uint32_t, uint32_t, uint32_t> wsl::windows::common::wslutil::ParseWsl
}
}
std::pair<std::string, std::optional<std::string>> wsl::windows::common::wslutil::ParseImage(const std::string& Input)
std::pair<std::string, std::optional<std::string>> wsl::windows::common::wslutil::ParseImage(const std::string& Input, EnumReferenceFormat* Format)
{
static const auto regex = BuildImageReferenceRegex();
std::smatch match;
@@ -1258,18 +1258,25 @@ std::pair<std::string, std::optional<std::string>> wsl::windows::common::wslutil
THROW_HR_IF_MSG(E_UNEXPECTED, !repo.matched, "Unexpected regex match. Input: %hs", Input.c_str());
EnumReferenceFormat referenceFormat = EnumReferenceFormat::None;
std::optional<std::string> tagOrDigest;
if (digest.matched) // <repo>:[tag]@<digest> (If both digest and tag are specified, digest takes precedence).
{
return {repo.str(), digest.str()};
tagOrDigest = digest.str();
referenceFormat = EnumReferenceFormat::Digest;
}
else if (tag.matched) // <repo>:<tag>
{
return {repo.str(), tag.str()}; // <repo>
tagOrDigest = tag.str();
referenceFormat = EnumReferenceFormat::Tag;
}
else
if (Format)
{
return {repo.str(), std::nullopt};
*Format = referenceFormat;
}
return {repo.str(), std::move(tagOrDigest)};
}
void wsl::windows::common::wslutil::PrintSystemError(_In_ HRESULT result, _Inout_ FILE* const stream)

View File

@@ -48,6 +48,13 @@ inline auto c_vhdFileExtension = L".vhd";
inline auto c_vhdxFileExtension = L".vhdx";
inline constexpr auto c_vmOwner = L"WSL"; // TODO-WSLC: Does this apply to WSLC ?
enum class EnumReferenceFormat
{
None,
Tag,
Digest
};
struct GitHubReleaseAsset
{
std::wstring url;
@@ -276,7 +283,7 @@ void ParseIpv6Address(const char* Address, in_addr6& Result);
std::tuple<uint32_t, uint32_t, uint32_t> ParseWslPackageVersion(_In_ const std::wstring& Version);
std::pair<std::string, std::optional<std::string>> ParseImage(const std::string& Input);
std::pair<std::string, std::optional<std::string>> ParseImage(const std::string& Input, EnumReferenceFormat* Format = nullptr);
void PrintSystemError(_In_ HRESULT result, _Inout_ FILE* stream = stdout);

View File

@@ -74,7 +74,9 @@ _(Session, "session", NO_ALIAS, Kind::Value, L
_(SessionId, "session-id", NO_ALIAS, Kind::Positional, Localization::WSLCCLI_SessionIdPositionalArgDescription()) \
_(StoragePath, "storage-path", NO_ALIAS, Kind::Positional, L"Path to the session storage directory") \
_(Signal, "signal", L"s", Kind::Value, Localization::WSLCCLI_SignalArgDescription(L"SIGKILL")) \
_(Source, "source", NO_ALIAS, Kind::Positional, Localization::WSLCCLI_SourceArgDescription()) \
_(Tag, "tag", L"t", Kind::Value, Localization::WSLCCLI_TagArgDescription()) \
_(Target, "target", NO_ALIAS, Kind::Positional, Localization::WSLCCLI_TargetArgDescription()) \
_(Time, "time", L"t", Kind::Value, Localization::WSLCCLI_TimeArgDescription()) \
_(TMPFS, "tmpfs", NO_ALIAS, Kind::Value, Localization::WSLCCLI_TMPFSArgDescription()) \
_(TTY, "tty", L"t", Kind::Flag, Localization::WSLCCLI_TTYArgDescription()) \

View File

@@ -29,6 +29,7 @@ std::vector<std::unique_ptr<Command>> ImageCommand::GetCommands() const
commands.push_back(std::make_unique<ImageLoadCommand>(FullName()));
commands.push_back(std::make_unique<ImagePullCommand>(FullName()));
commands.push_back(std::make_unique<ImageSaveCommand>(FullName()));
commands.push_back(std::make_unique<ImageTagCommand>(FullName()));
return commands;
}

View File

@@ -157,6 +157,21 @@ struct ImageSaveCommand final : public Command
std::wstring ShortDescription() const override;
std::wstring LongDescription() const override;
protected:
void ExecuteInternal(CLIExecutionContext& context) const override;
};
// Tag Command
struct ImageTagCommand final : public Command
{
constexpr static std::wstring_view CommandName = L"tag";
ImageTagCommand(const std::wstring& parent) : Command(CommandName, parent)
{
}
std::vector<Argument> GetArguments() const override;
std::wstring ShortDescription() const override;
std::wstring LongDescription() const override;
protected:
void ExecuteInternal(CLIExecutionContext& context) const override;
};

View File

@@ -0,0 +1,52 @@
/*++
Copyright (c) Microsoft. All rights reserved.
Module Name:
ImageTagCommand.cpp
Abstract:
Implementation of command execution logic.
--*/
#include "ImageCommand.h"
#include "CLIExecutionContext.h"
#include "ImageTasks.h"
#include "SessionTasks.h"
#include "Task.h"
using namespace wsl::shared;
using namespace wsl::windows::wslc::execution;
using namespace wsl::windows::wslc::task;
namespace wsl::windows::wslc {
// Image Tag Command
std::vector<Argument> ImageTagCommand::GetArguments() const
{
return {
Argument::Create(ArgType::Source, true),
Argument::Create(ArgType::Target, true),
Argument::Create(ArgType::Session),
};
}
std::wstring ImageTagCommand::ShortDescription() const
{
return Localization::WSLCCLI_ImageTagDesc();
}
std::wstring ImageTagCommand::LongDescription() const
{
return Localization::WSLCCLI_ImageTagLongDesc();
}
void ImageTagCommand::ExecuteInternal(CLIExecutionContext& context) const
{
context //
<< CreateSession //
<< TagImage;
}
} // namespace wsl::windows::wslc

View File

@@ -48,6 +48,7 @@ std::vector<std::unique_ptr<Command>> RootCommand::GetCommands() const
commands.push_back(std::make_unique<ImageSaveCommand>(FullName()));
commands.push_back(std::make_unique<ContainerStartCommand>(FullName()));
commands.push_back(std::make_unique<ContainerStopCommand>(FullName()));
commands.push_back(std::make_unique<ImageTagCommand>(FullName()));
commands.push_back(std::make_unique<VersionCommand>(FullName()));
return commands;
}

View File

@@ -199,6 +199,23 @@ void ImageService::Pull(wsl::windows::wslc::models::Session& session, const std:
THROW_IF_FAILED(session.Get()->PullImage(image.c_str(), nullptr, callback));
}
void ImageService::Tag(wsl::windows::wslc::models::Session& session, const std::string& sourceImage, const std::string& targetImage)
{
EnumReferenceFormat format;
auto [repo, tag] = ParseImage(targetImage, &format);
if (format == EnumReferenceFormat::Digest)
{
THROW_HR_WITH_USER_ERROR(E_INVALIDARG, Localization::MessageWslcTagImageInvalidFormat(targetImage.c_str()));
}
WSLCTagImageOptions options{};
options.Image = sourceImage.c_str();
options.Repo = repo.c_str();
options.Tag = tag ? tag->c_str() : "";
THROW_IF_FAILED(session.Get()->TagImage(&options));
}
InspectImage ImageService::Inspect(wsl::windows::wslc::models::Session& session, const std::string& image)
{
wil::unique_cotaskmem_ansistring inspectData;
@@ -221,10 +238,6 @@ void ImageService::Save(wsl::windows::wslc::models::Session& session, const std:
THROW_IF_FAILED(session.Get()->SaveImage(ToCOMInputHandle(outputFile.get()), image.c_str(), nullptr, cancelEvent));
}
void ImageService::Tag()
{
}
void ImageService::Prune()
{
}

View File

@@ -37,8 +37,8 @@ public:
static wsl::windows::common::wslc_schema::InspectImage Inspect(wsl::windows::wslc::models::Session& session, const std::string& image);
static void Pull(wsl::windows::wslc::models::Session& session, const std::string& image, IProgressCallback* callback);
static void Save(wsl::windows::wslc::models::Session& session, const std::string& image, const std::wstring& output, HANDLE cancelEvent = nullptr);
static void Tag(wsl::windows::wslc::models::Session& session, const std::string& sourceImage, const std::string& targetImage);
void Push();
void Tag();
void Prune();
};
} // namespace wsl::windows::wslc::services

View File

@@ -194,4 +194,13 @@ void SaveImage(CLIExecutionContext& context)
auto& output = context.Args.Get<ArgType::Output>();
services::ImageService::Save(session, WideToMultiByte(imageId), output, context.CreateCancelEvent());
}
void TagImage(CLIExecutionContext& context)
{
WI_ASSERT(context.Data.Contains(Data::Session));
auto& session = context.Data.Get<Data::Session>();
auto& source = context.Args.Get<ArgType::Source>();
auto& target = context.Args.Get<ArgType::Target>();
services::ImageService::Tag(session, WideToMultiByte(source), WideToMultiByte(target));
}
} // namespace wsl::windows::wslc::task

View File

@@ -24,5 +24,6 @@ void LoadImage(CLIExecutionContext& context);
void PullImage(CLIExecutionContext& context);
void DeleteImage(CLIExecutionContext& context);
void InspectImages(CLIExecutionContext& context);
void TagImage(CLIExecutionContext& context);
void SaveImage(CLIExecutionContext& context);
} // namespace wsl::windows::wslc::task

View File

@@ -428,6 +428,7 @@ private:
{L"save", Localization::WSLCCLI_ImageSaveDesc()},
{L"start", Localization::WSLCCLI_ContainerStartDesc()},
{L"stop", Localization::WSLCCLI_ContainerStopDesc()},
{L"tag", Localization::WSLCCLI_ImageTagDesc()},
{L"version", Localization::WSLCCLI_VersionDesc()},
};

View File

@@ -13,6 +13,7 @@ Abstract:
#include "precomp.h"
#include "SessionModel.h"
#include "ImageModel.h"
#include "windows/Common.h"
#include "WSLCExecutor.h"
#include "WSLCE2EHelpers.h"
@@ -194,6 +195,23 @@ void VerifyImageIsNotUsed(const TestImage& image)
}
}
void VerifyImageIsListed(const TestImage& image)
{
auto result = RunWslc(L"image list --format json");
result.Verify({.Stderr = L"", .ExitCode = 0});
auto images = wsl::shared::FromJson<std::vector<wsl::windows::wslc::models::ImageInformation>>(result.Stdout.value().c_str());
for (const auto& img : images)
{
if (img.Repository == wsl::shared::string::WideToMultiByte(image.Name) &&
img.Tag == wsl::shared::string::WideToMultiByte(image.Tag))
{
return;
}
}
VERIFY_FAIL(std::format(L"Image '{}' not found in image list output", image.NameAndTag()).c_str());
}
std::string GetHashId(const std::string& id, bool fullId)
{
return wsl::windows::common::string::TruncateId(id, !fullId);

View File

@@ -119,6 +119,7 @@ private:
void VerifyContainerIsListed(const std::wstring& containerName, const std::wstring& status, const std::wstring& sessionName = L"");
void VerifyImageIsUsed(const TestImage& image);
void VerifyImageIsNotUsed(const TestImage& image);
void VerifyImageIsListed(const TestImage& image);
std::string GetHashId(const std::string& id, bool fullId = false);
wsl::windows::common::wslc_schema::InspectContainer InspectContainer(const std::wstring& containerName);

View File

@@ -0,0 +1,212 @@
/*++
Copyright (c) Microsoft. All rights reserved.
Module Name:
WSLCE2EImageTagTests.cpp
Abstract:
This file contains end-to-end tests for WSLC.
--*/
#include "precomp.h"
#include "windows/Common.h"
#include "WSLCExecutor.h"
#include "WSLCE2EHelpers.h"
namespace WSLCE2ETests {
using namespace wsl::shared::string;
class WSLCE2EImageTagTests
{
WSL_TEST_CLASS(WSLCE2EImageTagTests)
TEST_METHOD_SETUP(MethodSetup)
{
EnsureImageIsDeleted(DebianTaggedImage);
EnsureImageIsLoaded(DebianImage);
EnsureImageIsLoaded(AlpineImage);
return true;
}
TEST_CLASS_CLEANUP(ClassCleanup)
{
EnsureImageIsDeleted(DebianTaggedImage);
EnsureImageIsDeleted(DebianImage);
EnsureImageIsDeleted(AlpineImage);
return true;
}
WSLC_TEST_METHOD(WSLCE2E_Image_Tag_HelpCommand)
{
auto result = RunWslc(L"image tag --help");
result.Verify({.Stdout = GetHelpMessage(), .Stderr = L"", .ExitCode = 0});
}
WSLC_TEST_METHOD(WSLCE2E_Image_Tag_MissingSourceAndTarget)
{
auto result = RunWslc(L"image tag");
result.Verify({.Stdout = GetHelpMessage(), .Stderr = L"Required argument not provided: 'source'\r\n", .ExitCode = 1});
}
WSLC_TEST_METHOD(WSLCE2E_Image_Tag_MissingTarget)
{
auto result = RunWslc(std::format(L"image tag {}", DebianImage.NameAndTag()));
result.Verify({.Stdout = GetHelpMessage(), .Stderr = L"Required argument not provided: 'target'\r\n", .ExitCode = 1});
}
WSLC_TEST_METHOD(WSLCE2E_Image_Tag_SourceImageNotFound)
{
auto result = RunWslc(std::format(L"image tag {} {}", InvalidImage.NameAndTag(), DebianTaggedImage.NameAndTag()));
auto errorMessage = std::format(L"No such image: {}\r\nError code: WSLC_E_IMAGE_NOT_FOUND\r\n", InvalidImage.NameAndTag());
result.Verify({.Stdout = L"", .Stderr = errorMessage, .ExitCode = 1});
}
WSLC_TEST_METHOD(WSLCE2E_Image_Tag_TargetImageWithDigest_Fail)
{
auto imageWithDigest = L"debian-mock:tag@sha256:11111111111111111111111111111111";
auto result = RunWslc(std::format(L"image tag {} {}", DebianImage.NameAndTag(), imageWithDigest));
auto errorMessage =
std::format(L"Invalid image tag format: '{}'. Expected format is 'name:tag'\r\nError code: E_INVALIDARG\r\n", imageWithDigest);
result.Verify({.Stdout = L"", .Stderr = errorMessage, .ExitCode = 1});
}
WSLC_TEST_METHOD(WSLCE2E_Image_Tag_Success)
{
auto result = RunWslc(std::format(L"image tag {} {}", DebianImage.NameAndTag(), DebianTaggedImage.NameAndTag()));
result.Verify({.Stdout = L"", .Stderr = L"", .ExitCode = 0});
VerifyImageIsListed(DebianImage);
VerifyImageIsListed(DebianTaggedImage);
auto resultSourceInspect = RunWslc(std::format(L"image inspect {}", DebianImage.NameAndTag()));
resultSourceInspect.Verify({.Stderr = L"", .ExitCode = 0});
auto sourceInspect = resultSourceInspect.Stdout;
auto resultTargetInspect = RunWslc(std::format(L"image inspect {}", DebianTaggedImage.NameAndTag()));
resultTargetInspect.Verify({.Stderr = L"", .ExitCode = 0});
auto targetInspect = resultTargetInspect.Stdout;
VERIFY_IS_TRUE(sourceInspect.has_value());
VERIFY_IS_TRUE(targetInspect.has_value());
VERIFY_ARE_EQUAL(sourceInspect, targetInspect);
}
WSLC_TEST_METHOD(WSLCE2E_Image_Tag_SourceAndTargetAreTheSame_Noop)
{
auto result = RunWslc(std::format(L"image tag {} {}", DebianImage.NameAndTag(), DebianImage.NameAndTag()));
result.Verify({.Stdout = L"", .Stderr = L"", .ExitCode = 0});
VerifyImageIsListed(DebianImage);
auto imageInspect = InspectImage(DebianImage.NameAndTag());
VERIFY_ARE_EQUAL(1u, imageInspect.RepoTags->size());
VERIFY_ARE_EQUAL(imageInspect.RepoTags->at(0), WideToMultiByte(DebianImage.NameAndTag()));
}
WSLC_TEST_METHOD(WSLCE2E_Image_Tag_TargetAlreadyExists_OverwritesTarget)
{
{
auto result = RunWslc(std::format(L"image tag {} {}", DebianImage.NameAndTag(), DebianTaggedImage.NameAndTag()));
result.Verify({.Stdout = L"", .Stderr = L"", .ExitCode = 0});
auto resultSourceInspect = RunWslc(std::format(L"image inspect {}", DebianImage.NameAndTag()));
resultSourceInspect.Verify({.Stderr = L"", .ExitCode = 0});
auto sourceInspect = resultSourceInspect.Stdout;
auto resultTargetInspect = RunWslc(std::format(L"image inspect {}", DebianTaggedImage.NameAndTag()));
resultTargetInspect.Verify({.Stderr = L"", .ExitCode = 0});
auto targetInspect = resultTargetInspect.Stdout;
VERIFY_IS_TRUE(sourceInspect.has_value());
VERIFY_IS_TRUE(targetInspect.has_value());
VERIFY_ARE_EQUAL(sourceInspect, targetInspect);
}
{
auto result = RunWslc(std::format(L"image tag {} {}", AlpineImage.NameAndTag(), DebianTaggedImage.NameAndTag()));
result.Verify({.Stdout = L"", .Stderr = L"", .ExitCode = 0});
auto resultSourceInspect = RunWslc(std::format(L"image inspect {}", AlpineImage.NameAndTag()));
resultSourceInspect.Verify({.Stderr = L"", .ExitCode = 0});
auto sourceInspect = resultSourceInspect.Stdout;
auto resultTargetInspect = RunWslc(std::format(L"image inspect {}", DebianTaggedImage.NameAndTag()));
resultTargetInspect.Verify({.Stderr = L"", .ExitCode = 0});
auto targetInspect = resultTargetInspect.Stdout;
VERIFY_IS_TRUE(sourceInspect.has_value());
VERIFY_IS_TRUE(targetInspect.has_value());
VERIFY_ARE_EQUAL(sourceInspect, targetInspect);
}
}
WSLC_TEST_METHOD(WSLCE2E_Image_Tag_DeleteSourceImage_TargetRemains)
{
auto result = RunWslc(std::format(L"image tag {} {}", DebianImage.NameAndTag(), DebianTaggedImage.NameAndTag()));
result.Verify({.Stdout = L"", .Stderr = L"", .ExitCode = 0});
EnsureImageIsDeleted(DebianImage);
VerifyImageIsListed(DebianTaggedImage);
}
WSLC_TEST_METHOD(WSLCE2E_Image_Tag_DeleteTargetImage_SourceRemains)
{
auto result = RunWslc(std::format(L"image tag {} {}", DebianImage.NameAndTag(), DebianTaggedImage.NameAndTag()));
result.Verify({.Stdout = L"", .Stderr = L"", .ExitCode = 0});
EnsureImageIsDeleted(DebianTaggedImage);
VerifyImageIsListed(DebianImage);
}
private:
const TestImage& DebianImage = DebianTestImage();
const TestImage& AlpineImage = AlpineTestImage();
const TestImage& InvalidImage = InvalidTestImage();
const TestImage DebianTaggedImage{L"debian", L"e2e-new-tag"};
std::wstring GetHelpMessage() const
{
std::wstringstream output;
output << GetWslcHeader() //
<< GetDescription() //
<< GetUsage() //
<< GetAvailableCommands() //
<< GetAvailableOptions();
return output.str();
}
std::wstring GetDescription() const
{
return wsl::shared::Localization::WSLCCLI_ImageTagLongDesc() + L"\r\n\r\n";
}
std::wstring GetUsage() const
{
return L"Usage: wslc image tag [<options>] <source> <target>\r\n\r\n";
}
std::wstring GetAvailableCommands() const
{
std::wstringstream commands;
commands << L"The following arguments are available:\r\n" //
<< L" source Current or existing image reference in the image-name[:tag] format\r\n" //
<< L" target New image reference in the image-name[:tag] format\r\n" //
<< L"\r\n";
return commands.str();
}
std::wstring GetAvailableOptions() const
{
std::wstringstream options;
options << L"The following options are available:\r\n" //
<< L" --session Specify the session to use\r\n" //
<< L" -h,--help Shows help about the selected command\r\n" //
<< L"\r\n";
return options.str();
}
};
} // namespace WSLCE2ETests

View File

@@ -74,6 +74,7 @@ private:
{L"load", Localization::WSLCCLI_ImageLoadDesc()},
{L"pull", Localization::WSLCCLI_ImagePullDesc()},
{L"save", Localization::WSLCCLI_ImageSaveDesc()},
{L"tag", Localization::WSLCCLI_ImageTagDesc()},
};
size_t maxLen = 0;