Skip to content

Commit

Permalink
Migrate from ContainerGroups/ContainerInstances to ContainerAppJobs (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
mburumaxwell authored Sep 18, 2023
1 parent 192352f commit c376fa4
Show file tree
Hide file tree
Showing 9 changed files with 100 additions and 160 deletions.
14 changes: 0 additions & 14 deletions server/Tingle.Dependabot.Tests/Workflow/UpdateRunnerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -220,18 +220,4 @@ public void ConvertPlaceholder_Works()
var result = UpdateRunner.ConvertPlaceholder(input, secrets);
Assert.Equal(":cake", result);
}

[Theory]
[InlineData("contoso.azurecr.io/tinglesoftware/dependabot-updater-nuget:1.20", true, "contoso.azurecr.io")]
[InlineData("fabrikam.azurecr.io/tinglesoftware/dependabot-updater-nuget:1.20", true, "fabrikam.azurecr.io")]
[InlineData("dependabot.azurecr.io/tinglesoftware/dependabot-updater-nuget:1.20", true, "dependabot.azurecr.io")]
[InlineData("ghcr.io/tinglesoftware/dependabot-updater-nuget:1.20", false, null)]
[InlineData("tingle/dependabot-updater-nuget:1.20", false, null)]
[InlineData("tingle/dependabot-azure-devops:1.20", false, null)]
public void TryGetAzureContainerRegistry_Works(string input, bool matches, string? expected)
{
var found = UpdateRunner.TryGetAzureContainerRegistry(input, out var actual);
Assert.Equal(matches, found);
Assert.Equal(expected, actual);
}
}
10 changes: 4 additions & 6 deletions server/Tingle.Dependabot/Models/UpdateJobResources.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using Azure.ResourceManager.ContainerInstance.Models;
using Azure.ResourceManager.AppContainers.Models;
using System.ComponentModel.DataAnnotations;

namespace Tingle.Dependabot.Models;
Expand Down Expand Up @@ -31,20 +31,18 @@ public UpdateJobResources(double cpu, double memory)

public static UpdateJobResources FromEcosystem(string ecosystem)
{
// the minimum we can be billed for on Container Instances is 1vCPU and 1GB, we might as well use it
// TODO: change to selection per ecosystem when migrate to ContainerApp Jobs
return ecosystem switch
{
//"nuget" => new(cpu: 0.25, memory: 0.2),
//"gitsubmodule" => new(cpu: 0.1, memory: 0.2),
//"terraform" => new(cpu: 0.25, memory: 1),
//"npm" => new(cpu: 0.25, memory: 1),
_ => new UpdateJobResources(cpu: 1, memory: 1), // the minimum
_ => new UpdateJobResources(cpu: 0.25, memory: 0.5), // the minimum
};
}

public static implicit operator ContainerResourceRequestsContent(UpdateJobResources resources)
public static implicit operator AppContainerResources(UpdateJobResources resources)
{
return new(memoryInGB: resources.Memory, cpu: resources.Cpu);
return new() { Cpu = resources.Cpu, Memory = $"{resources.Memory}Gi", };
}
}
2 changes: 1 addition & 1 deletion server/Tingle.Dependabot/Tingle.Dependabot.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@
<PackageReference Include="Azure.Identity" Version="1.10.1" />
<PackageReference Include="Azure.Monitor.Query" Version="1.2.0" />
<PackageReference Include="Azure.ResourceManager.AppContainers" Version="1.1.0" />
<PackageReference Include="Azure.ResourceManager.ContainerInstance" Version="1.1.0" />
<PackageReference Include="FlakeId" Version="1.1.1" />
<PackageReference Include="Macross.Json.Extensions" Version="3.0.0" />
<PackageReference Include="Microsoft.ApplicationInsights.AspNetCore" Version="2.21.0" />
Expand All @@ -35,6 +34,7 @@
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.19.5" />
<PackageReference Include="Microsoft.VisualStudio.Services.ServiceHooks.WebApi" Version="19.225.0-preview" />
<PackageReference Include="MiniValidation" Version="0.8.0" />
<PackageReference Include="System.Linq.Async" Version="6.0.1" />
<PackageReference Include="Tingle.EventBus.Transports.Azure.ServiceBus" Version="0.19.2" />
<PackageReference Include="Tingle.EventBus.Transports.InMemory" Version="0.19.2" />
<PackageReference Include="Tingle.Extensions.DataAnnotations" Version="4.2.1" />
Expand Down
155 changes: 80 additions & 75 deletions server/Tingle.Dependabot/Workflow/UpdateRunner.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
using Azure.Identity;
using Azure.Monitor.Query;
using Azure.ResourceManager;
using Azure.ResourceManager.ContainerInstance;
using Azure.ResourceManager.ContainerInstance.Models;
using Azure.ResourceManager.AppContainers;
using Azure.ResourceManager.AppContainers.Models;
using Azure.ResourceManager.Resources;
using Microsoft.Extensions.Options;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Tingle.Dependabot.Models;

Expand All @@ -17,9 +17,6 @@ internal partial class UpdateRunner
[GeneratedRegex("\\${{\\s*([a-zA-Z_]+[a-zA-Z0-9_-]*)\\s*}}", RegexOptions.Compiled)]
private static partial Regex PlaceholderPattern();

[GeneratedRegex("^((?:[a-zA-Z0-9-_]+)\\.azurecr\\.io)\\/")]
private static partial Regex ContainerRegistryPattern();

private const string UpdaterContainerName = "updater";

private static readonly JsonSerializerOptions serializerOptions = new(JsonSerializerDefaults.Web);
Expand All @@ -45,53 +42,73 @@ public async Task CreateAsync(Repository repository, RepositoryUpdate update, Up
var resourceName = MakeResourceName(job);

// if we have an existing one, there is nothing more to do
var containerGroups = resourceGroup.GetContainerGroups();
var containerAppJobs = resourceGroup.GetContainerAppJobs();
try
{
var response = await containerGroups.GetAsync(resourceName, cancellationToken);
var response = await containerAppJobs.GetAsync(resourceName, cancellationToken);
if (response.Value is not null) return;
}
catch (Azure.RequestFailedException rfe) when (rfe.Status is 404) { }

// prepare the container
var fileShareName = options.FileShareName;
var volumeName = "working-dir";
var image = options.UpdaterContainerImageTemplate!.Replace("{{ecosystem}}", job.PackageEcosystem);
var container = new ContainerInstanceContainer(UpdaterContainerName, image, new(job.Resources!));
var container = new ContainerAppContainer
{
Name = UpdaterContainerName,
Image = options.UpdaterContainerImageTemplate!.Replace("{{ecosystem}}", job.PackageEcosystem),
Resources = job.Resources!,
Args = { "update_script", },
VolumeMounts = { new ContainerAppVolumeMount { VolumeName = volumeName, MountPath = "/mnt/dependabot", }, },
};
var env = CreateVariables(repository, update, job);
foreach (var (key, value) in env) container.EnvironmentVariables.Add(new ContainerEnvironmentVariable(key) { Value = value, });

// set the container command/entrypoint (this is what seems to work)
container.Command.Add("/bin/bash");
container.Command.Add("bin/run.sh");
container.Command.Add("update_script");
foreach (var (key, value) in env) container.Env.Add(new ContainerAppEnvironmentVariable { Name = key, Value = value, });

// add volume mounts
container.VolumeMounts.Add(new ContainerVolumeMount(volumeName, "/mnt/dependabot"));

// prepare the container group
var data = new ContainerGroupData(options.Location!, new[] { container, }, ContainerInstanceOperatingSystemType.Linux)
// prepare the ContainerApp job
var data = new ContainerAppJobData(options.Location!)
{
RestartPolicy = ContainerGroupRestartPolicy.Never, // should run to completion without restarts
DiagnosticsLogAnalytics = new ContainerGroupLogAnalytics(options.LogAnalyticsWorkspaceId, options.LogAnalyticsWorkspaceKey),
EnvironmentId = options.AppEnvironmentId,
Configuration = new ContainerAppJobConfiguration(ContainerAppJobTriggerType.Manual, 1)
{
ManualTriggerConfig = new JobConfigurationManualTriggerConfig
{
Parallelism = 1,
ReplicaCompletionCount = 1,
},
ReplicaRetryLimit = 1,
ReplicaTimeout = Convert.ToInt32(TimeSpan.FromHours(1).TotalSeconds),
},
Template = new ContainerAppJobTemplate
{
Containers = { container, },
Volumes =
{
new ContainerAppVolume
{
Name = volumeName,
StorageType = ContainerAppStorageType.AzureFile,
StorageName = volumeName,
},
},
},

// add tags to the data for tracing purposes
Tags =
{
["purpose"] = "dependabot",
["ecosystem"] = job.PackageEcosystem,
["repository"] = repository.Slug,
["directory"] = update.Directory,
["machine-name"] = Environment.MachineName,
},
};

// add volumes
data.Volumes.Add(new ContainerVolume(volumeName)
{
AzureFile = new(fileShareName, options.StorageAccountName) { StorageAccountKey = options.StorageAccountKey, },
});

// add tags to the data for tracing purposes
data.Tags["purpose"] = "dependabot";
data.Tags.AddIfNotDefault("ecosystem", job.PackageEcosystem)
.AddIfNotDefault("repository", repository.Slug)
.AddIfNotDefault("directory", update.Directory)
.AddIfNotDefault("machine-name", Environment.MachineName);

// create the container group (do not wait completion because it might take too long, do not use the result)
_ = await containerGroups.CreateOrUpdateAsync(Azure.WaitUntil.Started, resourceName, data, cancellationToken);
logger.LogInformation("Created ContainerGroup for {UpdateJobId}", job.Id);
// create the ContainerApp Job
var operation = await containerAppJobs.CreateOrUpdateAsync(Azure.WaitUntil.Completed, resourceName, data, cancellationToken);
logger.LogInformation("Created ContainerApp Job for {UpdateJobId}", job.Id);

// start the ContainerApp Job
_ = await operation.Value.StartAsync(Azure.WaitUntil.Completed, cancellationToken: cancellationToken);
logger.LogInformation("Started ContainerApp Job for {UpdateJobId}", job.Id);
job.Status = UpdateJobStatus.Running;
}

Expand All @@ -102,8 +119,8 @@ public async Task DeleteAsync(UpdateJob job, CancellationToken cancellationToken
try
{
// if it does not exist, there is nothing more to do
var containerGroups = resourceGroup.GetContainerGroups();
var response = await containerGroups.GetAsync(resourceName, cancellationToken);
var containerAppJobs = resourceGroup.GetContainerAppJobs();
var response = await containerAppJobs.GetAsync(resourceName, cancellationToken);
if (response.Value is null) return;

// delete the container group
Expand All @@ -119,14 +136,26 @@ public async Task DeleteAsync(UpdateJob job, CancellationToken cancellationToken
try
{
// if it does not exist, there is nothing more to do
var response = await resourceGroup.GetContainerGroups().GetAsync(resourceName, cancellationToken);
var response = await resourceGroup.GetContainerAppJobAsync(resourceName, cancellationToken);
var resource = response.Value;

var status = resource.Data.InstanceView.State switch
// if there is no execution, there is nothing more to do
var executions = await resource.GetContainerAppJobExecutions().GetAllAsync(cancellationToken: cancellationToken).ToListAsync(cancellationToken: cancellationToken);
var execution = executions.SingleOrDefault();
if (execution is null) return null;

// this is a temporary workaround
// TODO: remove this after https://github.com/Azure/azure-sdk-for-net/issues/38385 is fixed
var rr = await resource.GetContainerAppJobExecutionAsync(execution.Data.Name, cancellationToken);
var properties = JsonNode.Parse(rr.GetRawResponse().Content.ToString())!.AsObject()["properties"]!;

//var status = execution.Data.Status.ToString() switch
var status = properties["status"]!.GetValue<string>() switch
{
"Succeeded" => UpdateJobStatus.Succeeded,
"Failed" => UpdateJobStatus.Failed,
_ => UpdateJobStatus.Running,
"Running" => UpdateJobStatus.Running,
"Processing" => UpdateJobStatus.Running,
_ => UpdateJobStatus.Failed,
};

// there is no state for jobs that are running
Expand All @@ -140,8 +169,8 @@ public async Task DeleteAsync(UpdateJob job, CancellationToken cancellationToken
}

// get the period
var currentState = resource.Data.Containers.Single(c => c.Name == UpdaterContainerName).InstanceView?.CurrentState;
DateTimeOffset? start = currentState?.StartOn, end = currentState?.FinishOn;
//DateTimeOffset? start = execution.Data.StartOn, end = execution.Data.EndOn;
DateTimeOffset? start = properties["startTime"]!.GetValue<DateTimeOffset?>(), end = properties["endTime"]!.GetValue<DateTimeOffset?>();

// create and return state
return new UpdateRunnerState(status, start, end);
Expand All @@ -156,22 +185,10 @@ public async Task DeleteAsync(UpdateJob job, CancellationToken cancellationToken
var logs = (string?)null;
var resourceName = MakeResourceName(job);

// pull logs from ContainerInstances
// pull logs from Log Analaytics
if (string.IsNullOrWhiteSpace(logs))
{
var query = $"ContainerInstanceLog_CL | where ContainerGroup_s == '{resourceName}' | order by TimeGenerated asc | project Message";
var response = await logsQueryClient.QueryWorkspaceAsync<string>(workspaceId: options.LogAnalyticsWorkspaceId,
query: query,
timeRange: QueryTimeRange.All,
cancellationToken: cancellationToken);

logs = string.Join(Environment.NewLine, response.Value);
}

// pull logs from ContainerApps
if (string.IsNullOrWhiteSpace(logs))
{
var query = $"ContainerAppConsoleLogs_CL | where ContainerAppName_s == '{resourceName}' | order by TimeGenerated asc | project Log_s";
var query = $"ContainerAppConsoleLogs_CL | where ContainerJobName_s == '{resourceName}' | order by _timestamp_d asc | project Log_s";
var response = await logsQueryClient.QueryWorkspaceAsync<string>(workspaceId: options.LogAnalyticsWorkspaceId,
query: query,
timeRange: QueryTimeRange.All,
Expand All @@ -183,19 +200,7 @@ public async Task DeleteAsync(UpdateJob job, CancellationToken cancellationToken
return logs;
}

internal static string MakeResourceName(UpdateJob job) => $"dependabot-job-{job.Id}";
internal static bool TryGetAzureContainerRegistry(string input, [NotNullWhen(true)] out string? registry)
{
registry = null;
var match = ContainerRegistryPattern().Match(input);
if (match.Success)
{
registry = match.Groups[1].Value;
return true;
}

return false;
}
internal static string MakeResourceName(UpdateJob job) => $"dependabot-{job.Id}";

internal IDictionary<string, string> CreateVariables(Repository repository, RepositoryUpdate update, UpdateJob job)
{
Expand Down
23 changes: 4 additions & 19 deletions server/Tingle.Dependabot/Workflow/WorkflowConfigureOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,14 @@ public ValidateOptionsResult Validate(string? name, WorkflowOptions options)
return ValidateOptionsResult.Fail($"'{nameof(options.ResourceGroupId)}' cannot be null or whitespace");
}

if (string.IsNullOrWhiteSpace(options.LogAnalyticsWorkspaceId))
if (string.IsNullOrWhiteSpace(options.AppEnvironmentId))
{
return ValidateOptionsResult.Fail($"'{nameof(options.LogAnalyticsWorkspaceId)}' cannot be null or whitespace");
return ValidateOptionsResult.Fail($"'{nameof(options.AppEnvironmentId)}' cannot be null or whitespace");
}

if (string.IsNullOrWhiteSpace(options.LogAnalyticsWorkspaceKey))
if (string.IsNullOrWhiteSpace(options.LogAnalyticsWorkspaceId))
{
return ValidateOptionsResult.Fail($"'{nameof(options.LogAnalyticsWorkspaceKey)}' cannot be null or whitespace");
return ValidateOptionsResult.Fail($"'{nameof(options.LogAnalyticsWorkspaceId)}' cannot be null or whitespace");
}

if (string.IsNullOrWhiteSpace(options.UpdaterContainerImageTemplate))
Expand All @@ -68,21 +68,6 @@ public ValidateOptionsResult Validate(string? name, WorkflowOptions options)
return ValidateOptionsResult.Fail($"'{nameof(options.Location)}' cannot be null or whitespace");
}

if (string.IsNullOrWhiteSpace(options.StorageAccountName))
{
return ValidateOptionsResult.Fail($"'{nameof(options.StorageAccountName)}' cannot be null or whitespace");
}

if (string.IsNullOrWhiteSpace(options.StorageAccountKey))
{
return ValidateOptionsResult.Fail($"'{nameof(options.StorageAccountKey)}' cannot be null or whitespace");
}

if (string.IsNullOrWhiteSpace(options.FileShareName))
{
return ValidateOptionsResult.Fail($"'{nameof(options.FileShareName)}' cannot be null or whitespace");
}

return ValidateOptionsResult.Success;
}
}
19 changes: 4 additions & 15 deletions server/Tingle.Dependabot/Workflow/WorkflowOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,14 @@ public class WorkflowOptions
/// <example>/subscriptions/00000000-0000-1111-0001-000000000000/resourceGroups/DEPENDABOT</example>
public string? ResourceGroupId { get; set; }

/// <summary>Name of the file share for the working directory</summary>
/// <example>/subscriptions/00000000-0000-1111-0001-000000000000/resourceGroups/DEPENDABOT/Microsoft.App/managedEnvironments/dependabot</example>
public string? AppEnvironmentId { get; set; }

/// <summary>CustomerId of the LogAnalytics workspace.</summary>
/// <example>00000000-0000-1111-0001-000000000000</example>
public string? LogAnalyticsWorkspaceId { get; set; }

/// <summary>AuthenticationKey of the LogAnalytics workspace.</summary>
/// <example>AAAAAAAAAAA=</example>
public string? LogAnalyticsWorkspaceKey { get; set; }

/// <summary>
/// Template representing the docker container image to use.
/// Keeping this value fixed in code is important so that the code that depends on it always works.
Expand Down Expand Up @@ -99,17 +99,6 @@ public class WorkflowOptions
/// <summary>Location/region where to create new update jobs.</summary>
public string? Location { get; set; } // using Azure.Core.Location does not work when binding from IConfiguration

/// <summary>Name of the storage account.</summary>
/// <example>dependabot-1234567890</example>
public string? StorageAccountName { get; set; } // only used with ContainerInstances

/// <summary>Access key for the storage account.</summary>
public string? StorageAccountKey { get; set; } // only used with ContainerInstances

/// <summary>Name of the file share for the working directory</summary>
/// <example>working-dir</example>
public string? FileShareName { get; set; } // only used with ContainerInstances

/// <summary>
/// Possible/allowed paths for the configuration files in a repository.
/// </summary>
Expand Down
Loading

0 comments on commit c376fa4

Please sign in to comment.