Skip to content

Commit 90b21ba

Browse files
Artifacts v4 (#287)
* Expermental Artifacts v4 * fix bug * fix macOS ci failure * add test and fix runner diag log upload * legacy v1 cannot download every artifact
1 parent 0b83f0e commit 90b21ba

File tree

9 files changed

+2854
-5
lines changed

9 files changed

+2854
-5
lines changed

Diff for: .github/workflows/nuget.yml

+1
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ jobs:
156156
gharun -C testworkflows/workflow_ref_and_job_workflow_ref
157157
gharun -C testworkflows/reusable-workflows-secrets-inherit-with-required-secrets -s TEST=topsecret -s OPT=testsec
158158
gharun -C testworkflows/inherit_vars --var ACTIONS_STEP_DEBUG=true
159+
gharun -C testworkflows/actions_artifacts_v4 -s ACTIONS_STEP_DEBUG=true -s ACTIONS_RUNNER_DEBUG=true --runner-version v2.311.0
159160
gharun --event azpipelines -C testworkflows/azpipelines/cross-repo-checkout -W testworkflows/azpipelines/cross-repo-checkout/pipeline.yml --local-repository az/containermatrix@main=testworkflows/azpipelines/containermatrix
160161
gharun --event azpipelines -C testworkflows/azpipelines/typedtemplates -W testworkflows/azpipelines/typedtemplates/pipeline.yml
161162
gharun --event azpipelines -C testworkflows/azpipelines/untypedtemplates -W testworkflows/azpipelines/untypedtemplates/pipeline.yml

Diff for: src/Runner.Sdk/GharunUtil.cs

+15-2
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,27 @@
44

55
namespace GitHub.Runner.Sdk {
66
public class GharunUtil {
7+
private static bool IsUsableLocalStorage(string localStorage) {
8+
return localStorage != "" && !localStorage.Contains(' ') && !localStorage.Contains('"') && !localStorage.Contains('\'');
9+
}
10+
711
public static string GetLocalStorage() {
812
var localStorage = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
9-
if(localStorage == "") {
13+
if(!IsUsableLocalStorage(localStorage)) {
1014
localStorage = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
1115
}
12-
if(localStorage == "") {
16+
if(!IsUsableLocalStorage(localStorage)) {
17+
localStorage = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
18+
if(IsUsableLocalStorage(localStorage)) {
19+
localStorage = Path.Join(localStorage, ".local", "share");
20+
}
21+
}
22+
if(!IsUsableLocalStorage(localStorage)) {
1323
localStorage = Path.GetDirectoryName(Assembly.GetEntryAssembly().Location);
1424
}
25+
if(!IsUsableLocalStorage(localStorage)) {
26+
localStorage = Path.GetTempPath();
27+
}
1528
return Path.GetFullPath(Path.Join(localStorage, "gharun"));
1629
}
1730

+183
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
using Microsoft.AspNetCore.Mvc;
2+
using Microsoft.AspNetCore.Authorization;
3+
using Microsoft.Extensions.Configuration;
4+
using Google.Protobuf;
5+
using System;
6+
using System.Threading.Tasks;
7+
using Microsoft.AspNetCore.Mvc.ModelBinding;
8+
using Microsoft.AspNetCore.Http;
9+
using System.IO;
10+
using System.Reflection;
11+
using Google.Protobuf.Reflection;
12+
using Microsoft.AspNetCore.Http.Extensions;
13+
using Runner.Server.Models;
14+
using System.Linq;
15+
using Microsoft.EntityFrameworkCore;
16+
using GitHub.Actions.Pipelines.WebApi;
17+
using System.Security.Cryptography;
18+
using Microsoft.IdentityModel.Tokens;
19+
using System.Text;
20+
21+
namespace Runner.Server.Controllers
22+
{
23+
24+
[ApiController]
25+
[Route("twirp/github.actions.results.api.v1.ArtifactService")]
26+
[Authorize(AuthenticationSchemes = "Bearer", Policy = "AgentJob")]
27+
public class ArtifactControllerV2 : VssControllerBase{
28+
private readonly SqLiteDb _context;
29+
private readonly JsonFormatter formatter;
30+
31+
public ArtifactControllerV2(SqLiteDb _context, IConfiguration configuration) : base(configuration)
32+
{
33+
this._context = _context;
34+
formatter = new JsonFormatter(JsonFormatter.Settings.Default.WithIndentation().WithPreserveProtoFieldNames(true).WithFormatDefaultValues(false));
35+
}
36+
37+
private string CreateSignature(int id) {
38+
using(var rsa = RSA.Create(Startup.AccessTokenParameter))
39+
return Base64UrlEncoder.Encode(rsa.SignData(Encoding.UTF8.GetBytes(id.ToString()), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1));
40+
}
41+
42+
private bool VerifySignature(int id, string sig) {
43+
using(var rsa = RSA.Create(Startup.AccessTokenParameter))
44+
return rsa.VerifyData(Encoding.UTF8.GetBytes(id.ToString()), Base64UrlEncoder.DecodeBytes(sig), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
45+
}
46+
47+
[HttpPost("CreateArtifact")]
48+
public async Task<string> CreateArtifact([FromBody, Protobuf] Github.Actions.Results.Api.V1.CreateArtifactRequest body) {
49+
var guid = Guid.Parse(body.WorkflowJobRunBackendId);
50+
var jobInfo = (from j in _context.Jobs where j.JobId == guid select new { j.runid, j.WorkflowRunAttempt.Attempt }).FirstOrDefault();
51+
var artifacts = new ArtifactController(_context, Configuration);
52+
var fname = $"{body.Name}.zip";
53+
var container = await artifacts.CreateContainer(jobInfo.runid, jobInfo.Attempt, new CreateActionsStorageArtifactParameters() { Name = body.Name }, jobInfo.Attempt);
54+
if(_context.Entry(container).Collection(c => c.Files).Query().Any()) {
55+
//var files = _context.Entry(container).Collection(c => c.Files).Query().ToList();
56+
// Duplicated Artifact of the same name in the same Attempt => fail
57+
return formatter.Format(new Github.Actions.Results.Api.V1.CreateArtifactResponse() {
58+
Ok = false
59+
});
60+
}
61+
var record = new ArtifactRecord() {FileName = fname, StoreName = Path.GetRandomFileName(), GZip = false, FileContainer = container} ;
62+
_context.ArtifactRecords.Add(record);
63+
await _context.SaveChangesAsync();
64+
65+
var resp = new Github.Actions.Results.Api.V1.CreateArtifactResponse
66+
{
67+
Ok = true,
68+
SignedUploadUrl = new Uri(new Uri(ServerUrl), $"twirp/github.actions.results.api.v1.ArtifactService/UploadArtifact?id={record.Id}&sig={CreateSignature(record.Id)}").ToString()
69+
};
70+
return formatter.Format(resp);
71+
}
72+
73+
[HttpPut("UploadArtifact")]
74+
[AllowAnonymous]
75+
public async Task<IActionResult> UploadArtifact(int id, string sig, string comp = null, bool seal = false) {
76+
if(string.IsNullOrEmpty(sig) || !VerifySignature(id, sig)) {
77+
return NotFound();
78+
}
79+
if(comp == "block" || comp == "appendBlock") {
80+
var record = await _context.ArtifactRecords.FindAsync(id);
81+
var _targetFilePath = Path.Combine(GitHub.Runner.Sdk.GharunUtil.GetLocalStorage(), "artifacts");
82+
using(var targetStream = new FileStream(Path.Combine(_targetFilePath, record.StoreName), FileMode.OpenOrCreate | FileMode.Append, FileAccess.Write, FileShare.Write)) {
83+
await Request.Body.CopyToAsync(targetStream);
84+
}
85+
return Created(HttpContext.Request.GetEncodedUrl(), null);
86+
}
87+
if(comp == "blocklist") {
88+
return Created(HttpContext.Request.GetEncodedUrl(), null);
89+
}
90+
return Ok();
91+
}
92+
93+
[HttpPost("FinalizeArtifact")]
94+
public string FinalizeArtifact([FromBody, Protobuf] Github.Actions.Results.Api.V1.FinalizeArtifactRequest body) {
95+
var attempt = long.Parse(User.FindFirst("attempt")?.Value ?? "-1");
96+
var artifactsMinAttempt = long.Parse(User.FindFirst("artifactsMinAttempt")?.Value ?? "-1");
97+
var runid = long.Parse(body.WorkflowRunBackendId);
98+
99+
var container = (from fileContainer in _context.ArtifactFileContainer where (fileContainer.Container.Attempt.Attempt >= artifactsMinAttempt || artifactsMinAttempt == -1) && (fileContainer.Container.Attempt.Attempt <= attempt || attempt == -1) && fileContainer.Container.Attempt.WorkflowRun.Id == runid && fileContainer.Files.Count == 1 && !fileContainer.Files.FirstOrDefault().FileName.Contains('/') && fileContainer.Files.FirstOrDefault().FileName.EndsWith(".zip") && body.Name.ToLower() == fileContainer.Name.ToLower() orderby fileContainer.Container.Attempt.Attempt descending select fileContainer).First();
100+
container.Size = body.Size;
101+
var resp = new Github.Actions.Results.Api.V1.FinalizeArtifactResponse
102+
{
103+
Ok = true,
104+
ArtifactId = container.Id
105+
};
106+
return formatter.Format(resp);
107+
}
108+
109+
[HttpPost("ListArtifacts")]
110+
public string ListArtifacts([FromBody, Protobuf] Github.Actions.Results.Api.V1.ListArtifactsRequest body) {
111+
var resp = new Github.Actions.Results.Api.V1.ListArtifactsResponse();
112+
113+
var attempt = long.Parse(User.FindFirst("attempt")?.Value ?? "-1");
114+
var artifactsMinAttempt = long.Parse(User.FindFirst("artifactsMinAttempt")?.Value ?? "-1");
115+
var runid = long.Parse(body.WorkflowRunBackendId);
116+
resp.Artifacts.AddRange(from fileContainer in _context.ArtifactFileContainer where (fileContainer.Container.Attempt.Attempt >= artifactsMinAttempt || artifactsMinAttempt == -1) && (fileContainer.Container.Attempt.Attempt <= attempt || attempt == -1) && fileContainer.Container.Attempt.WorkflowRun.Id == runid && fileContainer.Files.Count == 1 && !fileContainer.Files.FirstOrDefault().FileName.Contains('/') && fileContainer.Files.FirstOrDefault().FileName.EndsWith(".zip") && (body.IdFilter == null || body.IdFilter == fileContainer.Id) && (body.NameFilter == null || body.NameFilter.ToLower() == fileContainer.Name.ToLower()) orderby fileContainer.Container.Attempt.Attempt descending select new Github.Actions.Results.Api.V1.ListArtifactsResponse_MonolithArtifact
117+
{
118+
CreatedAt = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTimeOffset(System.DateTimeOffset.UtcNow),
119+
DatabaseId = fileContainer.Id,
120+
Name = fileContainer.Name,
121+
Size = fileContainer.Size ?? 0,
122+
WorkflowRunBackendId = body.WorkflowRunBackendId,
123+
WorkflowJobRunBackendId = body.WorkflowJobRunBackendId
124+
});
125+
return formatter.Format(resp);
126+
}
127+
128+
[HttpPost("GetSignedArtifactURL")]
129+
public async Task<string> GetSignedArtifactURL([FromBody, Protobuf] Github.Actions.Results.Api.V1.GetSignedArtifactURLRequest body) {
130+
var attempt = long.Parse(User.FindFirst("attempt")?.Value ?? "-1");
131+
var artifactsMinAttempt = long.Parse(User.FindFirst("artifactsMinAttempt")?.Value ?? "-1");
132+
var runid = long.Parse(body.WorkflowRunBackendId);
133+
var file = await (from fileContainer in _context.ArtifactFileContainer where (fileContainer.Container.Attempt.Attempt >= artifactsMinAttempt || artifactsMinAttempt == -1) && (fileContainer.Container.Attempt.Attempt <= attempt || attempt == -1) && fileContainer.Container.Attempt.WorkflowRun.Id == runid && fileContainer.Files.Count == 1 && fileContainer.Files.FirstOrDefault().FileName.ToLower() == $"{body.Name}.zip".ToLower() orderby fileContainer.Container.Attempt.Attempt descending select fileContainer.Files.FirstOrDefault()).FirstAsync();
134+
var resp = new Github.Actions.Results.Api.V1.GetSignedArtifactURLResponse
135+
{
136+
SignedUrl = new Uri(new Uri(ServerUrl), $"twirp/github.actions.results.api.v1.ArtifactService/DownloadArtifact?id={file.Id}&sig={CreateSignature(file.Id)}").ToString()
137+
};
138+
return formatter.Format(resp);
139+
}
140+
141+
[AllowAnonymous]
142+
[HttpGet("DownloadArtifact")]
143+
public IActionResult DownloadArtifact(int id, string sig) {
144+
if(string.IsNullOrEmpty(sig) || !VerifySignature(id, sig)) {
145+
return NotFound();
146+
}
147+
var container = _context.ArtifactRecords.Find(id);
148+
var _targetFilePath = Path.Combine(GitHub.Runner.Sdk.GharunUtil.GetLocalStorage(), "artifacts");
149+
return new FileStreamResult(System.IO.File.OpenRead(Path.Combine(_targetFilePath, container.StoreName)), "application/octet-stream") { EnableRangeProcessing = true };
150+
}
151+
152+
153+
public class ProtobufBinder : IModelBinder
154+
{
155+
public async Task BindModelAsync(ModelBindingContext bindingContext)
156+
{
157+
if (!bindingContext.HttpContext.Request.HasJsonContentType())
158+
{
159+
throw new BadHttpRequestException(
160+
"Request content type was not a recognized JSON content type.",
161+
StatusCodes.Status415UnsupportedMediaType);
162+
}
163+
164+
using var sr = new StreamReader(bindingContext.HttpContext.Request.Body);
165+
var str = await sr.ReadToEndAsync();
166+
167+
var valueType = bindingContext.ModelType;
168+
var parser = new JsonParser(JsonParser.Settings.Default.WithIgnoreUnknownFields(true));
169+
170+
var descriptor = (MessageDescriptor)bindingContext.ModelType.GetProperty("Descriptor", BindingFlags.Public | BindingFlags.Static).GetValue(null, null);
171+
var obj = parser.Parse(str, descriptor);
172+
173+
bindingContext.Result = ModelBindingResult.Success(obj);
174+
}
175+
}
176+
177+
public class ProtobufAttribute : ModelBinderAttribute {
178+
public ProtobufAttribute() : base(typeof(ProtobufBinder)) {
179+
180+
}
181+
}
182+
}
183+
}

Diff for: src/Runner.Server/Controllers/MessageController.cs

+2
Original file line numberDiff line numberDiff line change
@@ -5581,6 +5581,7 @@ private Func<bool, Job> queueJob(GitHub.DistributedTask.ObjectTemplating.ITraceW
55815581
var feedStreamUrl = new UriBuilder(new Uri(new Uri(apiUrl), $"_apis/v1/TimeLineWebConsoleLog/feedstream/{Uri.EscapeDataString(timelineId.ToString())}/ws"));
55825582
feedStreamUrl.Scheme = feedStreamUrl.Scheme == "http" ? "ws" : "wss";
55835583
systemVssConnection.Data["FeedStreamUrl"] = feedStreamUrl.ToString();
5584+
systemVssConnection.Data["ResultsServiceUrl"] = apiUrl;
55845585
if(calculatedPermissions.TryGetValue("id_token", out var p_id_token) && p_id_token == "write") {
55855586
var environment = deploymentEnvironmentValue?.Name ?? ("");
55865587
var claims = new Dictionary<string, string>();
@@ -5711,6 +5712,7 @@ private Func<bool, Job> queueJob(GitHub.DistributedTask.ObjectTemplating.ITraceW
57115712
new Claim("localcheckout", localcheckout ? "actions/checkout" : ""),
57125713
new Claim("runid", runid.ToString()),
57135714
new Claim("github_token", variables.TryGetValue("github_token", out var ghtoken) ? ghtoken.Value : ""),
5715+
new Claim("scp", $"Actions.Results:{runid}:{job.JobId}")
57145716
}),
57155717
Expires = DateTime.UtcNow.AddMinutes(timeoutMinutes + 10),
57165718
Issuer = myIssuer,

Diff for: src/Runner.Server/Controllers/TimelineController.cs

+4-3
Original file line numberDiff line numberDiff line change
@@ -89,9 +89,10 @@ public async Task<IActionResult> GetTimeline(Guid timelineId) {
8989
public async Task<IActionResult> PutAttachment(Guid timelineId, Guid recordId, string type, string name) {
9090
var jobInfo = (from j in _context.Jobs where j.TimeLineId == timelineId select new { j.runid, j.Attempt }).FirstOrDefault();
9191
var artifacts = new ArtifactController(_context, Configuration);
92-
var fname = $"Attachment_{timelineId}_{recordId}_{type}";
93-
var container = await artifacts.CreateContainer(jobInfo.runid, jobInfo.Attempt, new CreateActionsStorageArtifactParameters() { Name = fname });
94-
var record = new ArtifactRecord() {FileName = Path.Join(fname, name), StoreName = Path.GetRandomFileName(), GZip = false, FileContainer = container} ;
92+
var prefix = $"Attachment_{timelineId}_{recordId}";
93+
var fname = $"{prefix}_{type}_{name}";
94+
var container = await artifacts.CreateContainer(jobInfo.runid, jobInfo.Attempt, new CreateActionsStorageArtifactParameters() { Name = prefix });
95+
var record = new ArtifactRecord() {FileName = fname, StoreName = Path.GetRandomFileName(), GZip = false, FileContainer = container} ;
9596
_context.ArtifactRecords.Add(record);
9697
await _context.SaveChangesAsync();
9798
var _targetFilePath = Path.Combine(GitHub.Runner.Sdk.GharunUtil.GetLocalStorage(), "artifacts");

0 commit comments

Comments
 (0)