Add endpoint to download the agent binaries (#2600)

* Add endpoint to download the agent binaries

* build fix

* fix types

* adding test

* format

* remove comments

* address pr comment

* build fix
This commit is contained in:
Cheick Keita
2022-11-07 15:28:22 -08:00
committed by GitHub
parent f7dd265fdd
commit 7e9ff9cd11
6 changed files with 170 additions and 11 deletions

View File

@ -0,0 +1,31 @@
using System.Net;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
namespace Microsoft.OneFuzz.Service.Functions;
public class Tools {
private readonly IOnefuzzContext _context;
private readonly IEndpointAuthorization _auth;
public Tools(IEndpointAuthorization auth, IOnefuzzContext context) {
_context = context;
_auth = auth;
}
public async Async.Task<HttpResponseData> GetResponse(HttpRequestData req) {
//Note: streaming response are not currently supported by in isolated functions
// https://github.com/Azure/azure-functions-dotnet-worker/issues/958
var response = req.CreateResponse(HttpStatusCode.OK);
var downloadResult = await _context.Containers.DownloadAsZip(WellKnownContainers.Tools, StorageType.Config, response.Body);
if (!downloadResult.IsOk) {
return await _context.RequestHandling.NotOk(req, downloadResult.ErrorV, "download tools");
}
return response;
}
[Function("Tools")]
public Async.Task<HttpResponseData> Run([HttpTrigger(AuthorizationLevel.Anonymous, "GET")] HttpRequestData req)
=> _auth.CallIfUser(req, GetResponse);
}

View File

@ -29,6 +29,7 @@ public enum ErrorCode {
PROXY_FAILED = 472, PROXY_FAILED = 472,
INVALID_CONFIGURATION = 473, INVALID_CONFIGURATION = 473,
UNABLE_TO_CREATE_CONTAINER = 474, UNABLE_TO_CREATE_CONTAINER = 474,
UNABLE_TO_DOWNLOAD_FILE = 475,
} }
public enum VmState { public enum VmState {

View File

@ -1,8 +1,11 @@
using System.Threading; using System.IO;
using System.IO.Compression;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Azure; using Azure;
using Azure.Storage.Blobs; using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models; using Azure.Storage.Blobs.Models;
using Azure.Storage.Blobs.Specialized;
using Azure.Storage.Sas; using Azure.Storage.Sas;
namespace Microsoft.OneFuzz.Service; namespace Microsoft.OneFuzz.Service;
@ -31,6 +34,7 @@ public interface IContainers {
public Async.Task<Dictionary<Container, IDictionary<string, string>>> GetContainers(StorageType corpus); public Async.Task<Dictionary<Container, IDictionary<string, string>>> GetContainers(StorageType corpus);
public string AuthDownloadUrl(Container container, string filename); public string AuthDownloadUrl(Container container, string filename);
public Async.Task<OneFuzzResultVoid> DownloadAsZip(Container container, StorageType storageType, Stream stream, string? prefix = null);
} }
public class Containers : IContainers { public class Containers : IContainers {
@ -234,4 +238,21 @@ public class Containers : IContainers {
return $"{instance}/api/download?{queryString}"; return $"{instance}/api/download?{queryString}";
} }
public async Async.Task<OneFuzzResultVoid> DownloadAsZip(Container container, StorageType storageType, Stream stream, string? prefix = null) {
var client = await FindContainer(container, storageType) ?? throw new Exception($"unable to find container: {container} - {storageType}");
var blobs = client.GetBlobs(prefix: prefix);
using var archive = new ZipArchive(stream, ZipArchiveMode.Create, true);
await foreach (var b in blobs.ToAsyncEnumerable()) {
var entry = archive.CreateEntry(b.Name);
await using var entryStream = entry.Open();
var blobClient = client.GetBlockBlobClient(b.Name);
var downloadResult = await blobClient.DownloadToAsync(entryStream);
if (downloadResult.IsError) {
return OneFuzzResultVoid.Error(ErrorCode.UNABLE_TO_DOWNLOAD_FILE, $"Error while downloading blob {b.Name}");
}
}
return OneFuzzResultVoid.Ok;
}
} }

View File

@ -0,0 +1,66 @@
using System;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Net;
using IntegrationTests.Fakes;
using Microsoft.OneFuzz.Service;
using Microsoft.OneFuzz.Service.Functions;
using Xunit;
using Xunit.Abstractions;
using Async = System.Threading.Tasks;
namespace IntegrationTests;
[Trait("Category", "Live")]
public class AzureStorageToolsTest : ToolsTestBase {
public AzureStorageToolsTest(ITestOutputHelper output)
: base(output, Integration.AzureStorage.FromEnvironment()) { }
}
public class AzuriteToolsTest : ToolsTestBase {
public AzuriteToolsTest(ITestOutputHelper output)
: base(output, new Integration.AzuriteStorage()) { }
}
public abstract class ToolsTestBase : FunctionTestBase {
private readonly IStorage _storage;
public ToolsTestBase(ITestOutputHelper output, IStorage storage)
: base(output, storage) {
_storage = storage;
}
[Fact]
public async Async.Task CanDownload() {
const int NUMBER_OF_FILES = 20;
var toolsContainerClient = GetContainerClient(WellKnownContainers.Tools);
_ = await toolsContainerClient.CreateIfNotExistsAsync();
// generate random content
var files = Enumerable.Range(0, NUMBER_OF_FILES).Select((x, i) => (path: i, content: Guid.NewGuid())).ToList();
// upload each files
foreach (var (path, content) in files) {
var r = await toolsContainerClient.UploadBlobAsync(path.ToString(), BinaryData.FromString(content.ToString()));
Assert.False(r.GetRawResponse().IsError);
}
var auth = new TestEndpointAuthorization(RequestType.User, Logger, Context);
var func = new Tools(auth, Context);
var result = await func.Run(TestHttpRequestData.FromJson("GET", ""));
Assert.Equal(HttpStatusCode.OK, result.StatusCode);
using var zipArchive = new ZipArchive(result.Body);
foreach (var entry in zipArchive.Entries) {
Assert.True(int.TryParse(entry.Name, out var index));
Assert.True(index >= 0 && index < files.Count);
using var entryStream = entry.Open();
using var sr = new StreamReader(entryStream);
var actualContent = sr.ReadToEnd();
Assert.Equal(files[index].content.ToString(), actualContent);
}
}
}

View File

@ -29,6 +29,7 @@ from onefuzztypes import (
) )
from onefuzztypes.enums import TaskType from onefuzztypes.enums import TaskType
from pydantic import BaseModel from pydantic import BaseModel
from requests import Response
from six.moves import input # workaround for static analysis from six.moves import input # workaround for static analysis
from .__version__ import __version__ from .__version__ import __version__
@ -94,6 +95,23 @@ class Endpoint:
self.onefuzz = onefuzz self.onefuzz = onefuzz
self.logger = onefuzz.logger self.logger = onefuzz.logger
def _req_base(
self,
method: str,
*,
data: Optional[BaseModel] = None,
as_params: bool = False,
alternate_endpoint: Optional[str] = None,
) -> Response:
endpoint = self.endpoint if alternate_endpoint is None else alternate_endpoint
if as_params:
response = self.onefuzz._backend.request(method, endpoint, params=data)
else:
response = self.onefuzz._backend.request(method, endpoint, json_data=data)
return response
def _req_model( def _req_model(
self, self,
method: str, method: str,
@ -103,12 +121,12 @@ class Endpoint:
as_params: bool = False, as_params: bool = False,
alternate_endpoint: Optional[str] = None, alternate_endpoint: Optional[str] = None,
) -> A: ) -> A:
endpoint = self.endpoint if alternate_endpoint is None else alternate_endpoint response = self._req_base(
method,
if as_params: data=data,
response = self.onefuzz._backend.request(method, endpoint, params=data) as_params=as_params,
else: alternate_endpoint=alternate_endpoint,
response = self.onefuzz._backend.request(method, endpoint, json_data=data) ).json()
return model.parse_obj(response) return model.parse_obj(response)
@ -124,9 +142,13 @@ class Endpoint:
endpoint = self.endpoint if alternate_endpoint is None else alternate_endpoint endpoint = self.endpoint if alternate_endpoint is None else alternate_endpoint
if as_params: if as_params:
response = self.onefuzz._backend.request(method, endpoint, params=data) response = self.onefuzz._backend.request(
method, endpoint, params=data
).json()
else: else:
response = self.onefuzz._backend.request(method, endpoint, json_data=data) response = self.onefuzz._backend.request(
method, endpoint, json_data=data
).json()
return [model.parse_obj(x) for x in response] return [model.parse_obj(x) for x in response]
@ -1115,6 +1137,22 @@ class JobTasks(Endpoint):
return self.onefuzz.tasks.list(job_id=job_id, state=[]) return self.onefuzz.tasks.list(job_id=job_id, state=[])
class Tools(Endpoint):
"""Interact with tasks within a job"""
endpoint = "tools"
def get(self, destination: str) -> str:
"""Download a zip file containing the agent binaries"""
self.logger.debug("get tools")
response = self._req_base("GET")
path = os.path.join(destination, "tools.zip")
open(path, "wb").write(response.content)
return path
class Jobs(Endpoint): class Jobs(Endpoint):
"""Interact with Jobs""" """Interact with Jobs"""
@ -1720,6 +1758,7 @@ class Onefuzz:
self.scalesets = Scaleset(self) self.scalesets = Scaleset(self)
self.nodes = Node(self) self.nodes = Node(self)
self.webhooks = Webhooks(self) self.webhooks = Webhooks(self)
self.tools = Tools(self)
self.instance_config = InstanceConfigCmd(self) self.instance_config = InstanceConfigCmd(self)
if self._backend.is_feature_enabled(PreviewFeature.job_templates.name): if self._backend.is_feature_enabled(PreviewFeature.job_templates.name):

View File

@ -32,6 +32,7 @@ import msal
import requests import requests
from azure.storage.blob import ContainerClient from azure.storage.blob import ContainerClient
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from requests import Response
from tenacity import RetryCallState, retry from tenacity import RetryCallState, retry
from tenacity.retry import retry_if_exception_type from tenacity.retry import retry_if_exception_type
from tenacity.stop import stop_after_attempt from tenacity.stop import stop_after_attempt
@ -285,7 +286,7 @@ class Backend:
json_data: Optional[Any] = None, json_data: Optional[Any] = None,
params: Optional[Any] = None, params: Optional[Any] = None,
_retry_on_auth_failure: bool = True, _retry_on_auth_failure: bool = True,
) -> Any: ) -> Response:
if self.config.dotnet_functions and path in self.config.dotnet_functions: if self.config.dotnet_functions and path in self.config.dotnet_functions:
endpoint = self.config.dotnet_endpoint endpoint = self.config.dotnet_endpoint
else: else:
@ -349,7 +350,7 @@ class Backend:
"request did not succeed: HTTP %s - %s" "request did not succeed: HTTP %s - %s"
% (response.status_code, error_text) % (response.status_code, error_text)
) )
return response.json() return response
def before_sleep(retry_state: RetryCallState) -> None: def before_sleep(retry_state: RetryCallState) -> None: