Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 20 additions & 36 deletions src/code/ContainerRegistryServerAPICalls.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ internal class ContainerRegistryServerAPICalls : ServerApiCall
const string catalogScope = "&scope=registry:catalog:*";
const string grantTypeTemplate = "grant_type=access_token&service={0}{1}"; // 0 - registry, 1 - scope
const string authUrlTemplate = "{0}?service={1}{2}"; // 0 - realm, 1 - service, 2 - scope
const string emptyConfigContent = "{}";

const string containerRegistryRepositoryListTemplate = "https://{0}/v2/_catalog"; // 0 - registry

Expand Down Expand Up @@ -1568,17 +1569,16 @@ internal bool PushNupkgContainerRegistry(
return false;
}

// Create and upload an empty file-- needed by ContainerRegistry server
CreateAndUploadEmptyFile(outputNupkgDir, packageNameLowercase, containerRegistryAccessToken, out errRecord);
// Create config file
var configFilePath = System.IO.Path.Combine(outputNupkgDir, Guid.NewGuid().ToString() + ".json");
_cmdletPassedIn.WriteVerbose($"Create config file at path: {configFilePath}");
string configDigest = CreateConfigFile(configFilePath, out errRecord);
if (errRecord != null)
{
return false;
}

// Create config.json file
var configFilePath = System.IO.Path.Combine(outputNupkgDir, "config.json");
_cmdletPassedIn.WriteVerbose($"Create config.json file at path: {configFilePath}");
string configDigest = CreateConfigFile(configFilePath, out errRecord);
UploadConfigFile(configFilePath, configDigest, packageNameLowercase, containerRegistryAccessToken, out errRecord);
if (errRecord != null)
{
return false;
Expand Down Expand Up @@ -1667,38 +1667,27 @@ private string UploadNupkgFile(string packageNameLowercase, string containerRegi
}

/// <summary>
/// Uploads an empty file at the start of publish as is needed.
/// Uploads the config file referenced by the package manifest.
/// </summary>
private void CreateAndUploadEmptyFile(string outputNupkgDir, string pkgNameLower, string containerRegistryAccessToken, out ErrorRecord errRecord)
private void UploadConfigFile(string configFilePath, string configDigest, string pkgNameLower, string containerRegistryAccessToken, out ErrorRecord errRecord)
{
_cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::CreateAndUploadEmptyFile()");
_cmdletPassedIn.WriteVerbose("Create an empty file");
string emptyFileName = "empty" + Guid.NewGuid().ToString() + ".txt";
var emptyFilePath = System.IO.Path.Combine(outputNupkgDir, emptyFileName);
_cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::UploadConfigFile()");
errRecord = null;

try
{
Utils.CreateFile(emptyFilePath);

_cmdletPassedIn.WriteVerbose("Start uploading an empty file");
string emptyLocation = GetStartUploadBlobLocation(pkgNameLower, containerRegistryAccessToken).Result;

_cmdletPassedIn.WriteVerbose("Computing digest for empty file");
string emptyFileDigest = CreateDigest(emptyFilePath, out errRecord);
if (errRecord != null)
{
return;
}
_cmdletPassedIn.WriteVerbose("Start uploading the config file");
string configLocation = GetStartUploadBlobLocation(pkgNameLower, containerRegistryAccessToken).Result;

_cmdletPassedIn.WriteVerbose("Finish uploading empty file");
var emptyResponse = EndUploadBlob(emptyLocation, emptyFilePath, emptyFileDigest, false, containerRegistryAccessToken).Result;
bool uploadSuccessful = emptyResponse.IsSuccessStatusCode;
_cmdletPassedIn.WriteVerbose("Finish uploading the config file");
var configResponse = EndUploadBlob(configLocation, configFilePath, configDigest, false, containerRegistryAccessToken).Result;
bool uploadSuccessful = configResponse.IsSuccessStatusCode;

if (!uploadSuccessful)
{
errRecord = new ErrorRecord(
new UploadBlobException($"Error occurred while uploading blob, response code was: {emptyResponse.StatusCode} with reason {emptyResponse.ReasonPhrase}"),
"UploadEmptyFileError",
new UploadBlobException($"Error occurred while uploading blob, response code was: {configResponse.StatusCode} with reason {configResponse.ReasonPhrase}"),
"UploadConfigFileError",
ErrorCategory.InvalidResult,
_cmdletPassedIn);

Expand All @@ -1709,7 +1698,7 @@ private void CreateAndUploadEmptyFile(string outputNupkgDir, string pkgNameLower
{
errRecord = new ErrorRecord(
e,
"UploadEmptyFileError",
"UploadConfigFileError",
ErrorCategory.InvalidResult,
_cmdletPassedIn);

Expand All @@ -1725,14 +1714,9 @@ private string CreateConfigFile(string configFilePath, out ErrorRecord errRecord
_cmdletPassedIn.WriteDebug("In ContainerRegistryServerAPICalls::CreateConfigFile()");
string configFileDigest = string.Empty;
_cmdletPassedIn.WriteVerbose("Create the config file");
while (File.Exists(configFilePath))
{
configFilePath = Guid.NewGuid().ToString() + ".json";
}

try
{
Utils.CreateFile(configFilePath);
File.WriteAllText(configFilePath, emptyConfigContent);

_cmdletPassedIn.WriteVerbose("Computing digest for config");
configFileDigest = CreateDigest(configFilePath, out errRecord);
Expand Down Expand Up @@ -1837,7 +1821,7 @@ private string CreateManifestContent(
jsonWriter.WritePropertyName("digest");
jsonWriter.WriteValue($"sha256:{configDigest}");
jsonWriter.WritePropertyName("size");
jsonWriter.WriteValue(0);
jsonWriter.WriteValue(Encoding.UTF8.GetByteCount(emptyConfigContent));
jsonWriter.WriteEndObject();

jsonWriter.WritePropertyName("layers");
Expand Down
164 changes: 164 additions & 0 deletions test/PublishPSResourceTests/ContainerRegistryConfig.Tests.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.

Import-Module "$PSScriptRoot\..\PSGetTestUtils.psm1" -Force

Describe 'Container registry config blob' -Tags 'CI' {
BeforeAll {
Add-Type -AssemblyName System.Net.Http
$references = @(
[System.Net.Http.HttpClient].Assembly.Location
[System.Net.HttpStatusCode].Assembly.Location
)
Add-Type -ReferencedAssemblies $references -TypeDefinition @'
using System;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;

public class ConfigBlobUploadHandler : HttpMessageHandler
{
public byte[] UploadedContent { get; private set; }
public byte[] UploadedManifest { get; private set; }
public Uri UploadUri { get; private set; }
public HttpStatusCode UploadStatusCode = HttpStatusCode.Created;

protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (request.Method == HttpMethod.Post)
{
var response = new HttpResponseMessage(HttpStatusCode.Accepted);
response.Headers.Location = new Uri("/v2/test/blobs/uploads/id?upload=1", UriKind.Relative);
return response;
}

var content = await request.Content.ReadAsByteArrayAsync();
if (request.RequestUri.AbsolutePath.Contains("/manifests/"))
{
UploadedManifest = content;
}
else
{
UploadedContent = content;
UploadUri = request.RequestUri;
}

return new HttpResponseMessage(UploadStatusCode);
}
}
'@

function Invoke-ContainerRegistryMethod {
[CmdletBinding()]
param($MethodName, [object[]] $MethodArguments, $Handler)

$type = [Microsoft.PowerShell.PSResourceGet.Cmdlets.PublishPSResource].Assembly.GetType('Microsoft.PowerShell.PSResourceGet.ContainerRegistryServerAPICalls')
$repository = [Microsoft.PowerShell.PSResourceGet.UtilClasses.PSRepositoryInfo]::new(
'Test', [uri]'https://registry.example', 50, $true, $null, 'None', 'ContainerRegistry', $true)
$api = [Activator]::CreateInstance($type, @($repository, $PSCmdlet, $null, 'test'))
$flags = [Reflection.BindingFlags]'Instance,NonPublic'
$clientProperty = $type.GetProperty('_sessionClient', $flags)
try {
if ($Handler) {
$clientProperty.GetValue($api).Dispose()
$clientProperty.SetValue($api, [System.Net.Http.HttpClient]::new($Handler))
$type.GetField('_cachedContainterRegistryToken', $flags).SetValue($api, 'test-token')
}

for ($i = 0; $i -lt $MethodArguments.Length; $i++) {
if ($null -ne $MethodArguments[$i]) {
$MethodArguments[$i] = $MethodArguments[$i].PSObject.BaseObject
}
}

$type.GetMethod($MethodName, $flags).Invoke($api, $MethodArguments)
}
finally {
$clientProperty.GetValue($api).Dispose()
}
}
}

BeforeEach {
$configPath = Join-Path $TestDrive "$([guid]::NewGuid()).json"
$configArguments = @($configPath, $null)
$configDigest = Invoke-ContainerRegistryMethod 'CreateConfigFile' $configArguments
}

It 'Creates a config containing exactly an empty JSON object and hashes those bytes' {
$configArguments[1] | Should -BeNullOrEmpty
$content = [IO.File]::ReadAllBytes($configPath)
$content.Length | Should -Be 2
[Text.Encoding]::UTF8.GetString($content) | Should -BeExactly '{}'
$configDigest | Should -BeExactly (Get-FileHash $configPath -Algorithm SHA256).Hash.ToLowerInvariant()
}

It 'Describes the config bytes in the manifest without changing the package layer' {
$manifestArguments = @(
'packageDigest', $configDigest, [long]123, 'test.nupkg', 'test',
[Microsoft.PowerShell.PSResourceGet.UtilClasses.ResourceType]::Module, '{}')
$manifest = Invoke-ContainerRegistryMethod 'CreateManifestContent' $manifestArguments | ConvertFrom-Json

$manifest.config.mediaType | Should -BeExactly 'application/vnd.oci.image.config.v1+json'
$manifest.config.digest | Should -BeExactly "sha256:$configDigest"
$manifest.config.size | Should -Be (Get-Item $configPath).Length
$manifest.layers.Count | Should -Be 1
$manifest.layers[0].digest | Should -BeExactly 'sha256:packageDigest'
$manifest.layers[0].size | Should -Be 123
}

It 'Uploads the config JSON with its matching digest' {
$handler = [ConfigBlobUploadHandler]::new()
$uploadArguments = @($configPath, $configDigest, 'test', 'test-token', $null)
Invoke-ContainerRegistryMethod 'UploadConfigFile' $uploadArguments $handler

$uploadArguments[4] | Should -BeNullOrEmpty
$handler.UploadedContent.Length | Should -Be 2
[Text.Encoding]::UTF8.GetString($handler.UploadedContent) | Should -BeExactly '{}'
$handler.UploadUri.Query | Should -Match "digest=sha256:$configDigest"
}

It 'Reports a failed config upload' {
$handler = [ConfigBlobUploadHandler]::new()
$handler.UploadStatusCode = [System.Net.HttpStatusCode]::BadRequest
$uploadArguments = @($configPath, $configDigest, 'test', 'test-token', $null)
Invoke-ContainerRegistryMethod 'UploadConfigFile' $uploadArguments $handler

$uploadArguments[4].FullyQualifiedErrorId | Should -BeExactly 'UploadConfigFileError'
}

It 'Publishes matching config bytes and manifest when the module already contains config.json' {
$outputDirectory = Join-Path $TestDrive 'nupkg'
$null = New-Item $outputDirectory -ItemType Directory
$existingConfigPath = Join-Path $outputDirectory 'config.json'
$existingConfigContent = '{"module":true}'
[IO.File]::WriteAllText($existingConfigPath, $existingConfigContent)
[IO.File]::WriteAllText((Join-Path $outputDirectory 'test.1.0.0.nupkg'), 'package')

$handler = [ConfigBlobUploadHandler]::new()
$type = [Microsoft.PowerShell.PSResourceGet.Cmdlets.PublishPSResource].Assembly.GetType('Microsoft.PowerShell.PSResourceGet.ContainerRegistryServerAPICalls')
$versionType = $type.GetMethod('PushNupkgContainerRegistry', [Reflection.BindingFlags]'Instance,NonPublic').GetParameters()[3].ParameterType
$version = [Activator]::CreateInstance($versionType, @('1.0.0'))
$publishArguments = @(
$outputDirectory, 'test', $null, $version,
[Microsoft.PowerShell.PSResourceGet.UtilClasses.ResourceType]::Module,
@{ ModuleVersion = '1.0.0' }, @{}, $false, $null, $null)
Push-Location $TestDrive
try {
$published = Invoke-ContainerRegistryMethod 'PushNupkgContainerRegistry' $publishArguments $handler
}
finally {
Pop-Location
}

$published | Should -Be $true
$publishArguments[9] | Should -BeNullOrEmpty
[Text.Encoding]::UTF8.GetString($handler.UploadedContent) | Should -BeExactly '{}'
$handler.UploadUri.Query | Should -Match "digest=sha256:$configDigest"
$manifest = [Text.Encoding]::UTF8.GetString($handler.UploadedManifest) | ConvertFrom-Json
$manifest.config.digest | Should -BeExactly "sha256:$configDigest"
$manifest.config.size | Should -Be $handler.UploadedContent.Length
[IO.File]::ReadAllText($existingConfigPath) | Should -BeExactly $existingConfigContent
}
}