diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 563d712c1..77530e213 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -77,7 +77,7 @@ services: catalog.api: environment: - ASPNETCORE_ENVIRONMENT=Development - - ASPNETCORE_URLS=http://0.0.0.0:80 + - ASPNETCORE_URLS=http://0.0.0.0:80;https://0.0.0.0:443; - ConnectionString=${ESHOP_AZURE_CATALOG_DB:-Server=sql.data;Database=Microsoft.eShopOnContainers.Services.CatalogDb;User Id=sa;Password=Pass@word} - PicBaseUrl=${ESHOP_AZURE_STORAGE_CATALOG_URL:-http://localhost:5202/api/v1/c/catalog/items/[0]/pic/} #Local: You need to open your local dev-machine firewall at range 5100-5110. - EventBusConnection=${ESHOP_AZURE_SERVICE_BUS:-rabbitmq} @@ -91,8 +91,8 @@ services: - ApplicationInsights__InstrumentationKey=${INSTRUMENTATION_KEY} - OrchestratorType=${ORCHESTRATOR_TYPE} ports: - - "5101:80" # Important: In a production environment your should remove the external port (5101) kept here for microservice debugging purposes. - # The API Gateway redirects and access through the internal port (80). + - "5101:80" + - "9101:443" ordering.api: environment: diff --git a/src/Services/Catalog/Catalog.API/Catalog.API.csproj b/src/Services/Catalog/Catalog.API/Catalog.API.csproj index 6150179ee..2b66b6f0c 100644 --- a/src/Services/Catalog/Catalog.API/Catalog.API.csproj +++ b/src/Services/Catalog/Catalog.API/Catalog.API.csproj @@ -1,7 +1,7 @@  - netcoreapp2.2 + netcoreapp3.0 portable true Catalog.API @@ -32,8 +32,18 @@ + + + + + + + + + + @@ -43,18 +53,16 @@ - - - - + + - + - + @@ -63,7 +71,6 @@ - diff --git a/src/Services/Catalog/Catalog.API/Controllers/PicController.cs b/src/Services/Catalog/Catalog.API/Controllers/PicController.cs index 7d798597e..0aa376832 100644 --- a/src/Services/Catalog/Catalog.API/Controllers/PicController.cs +++ b/src/Services/Catalog/Catalog.API/Controllers/PicController.cs @@ -13,10 +13,10 @@ namespace Microsoft.eShopOnContainers.Services.Catalog.API.Controllers [ApiController] public class PicController : ControllerBase { - private readonly IHostingEnvironment _env; + private readonly IWebHostEnvironment _env; private readonly CatalogContext _catalogContext; - public PicController(IHostingEnvironment env, + public PicController(IWebHostEnvironment env, CatalogContext catalogContext) { _env = env; diff --git a/src/Services/Catalog/Catalog.API/Dockerfile b/src/Services/Catalog/Catalog.API/Dockerfile index facbb0ef3..d9f06d64d 100644 --- a/src/Services/Catalog/Catalog.API/Dockerfile +++ b/src/Services/Catalog/Catalog.API/Dockerfile @@ -1,8 +1,9 @@ -FROM mcr.microsoft.com/dotnet/core/aspnet:2.2 AS base +FROM mcr.microsoft.com/dotnet/core/aspnet:3.0-buster-slim AS base WORKDIR /app EXPOSE 80 +EXPOSE 443 -FROM mcr.microsoft.com/dotnet/core/sdk:2.2 AS build +FROM mcr.microsoft.com/dotnet/core/sdk:3.0-buster AS build WORKDIR /src COPY scripts scripts/ @@ -28,4 +29,6 @@ FROM build AS publish FROM base AS final WORKDIR /app COPY --from=publish /app . +COPY --from=build /src/src/Services/Catalog/Catalog.API/Proto /app/Proto +COPY --from=build /src/src/Services/Catalog/Catalog.API/eshop.pfx . ENTRYPOINT ["dotnet", "Catalog.API.dll"] diff --git a/src/Services/Catalog/Catalog.API/Extensions/HostExtensions.cs b/src/Services/Catalog/Catalog.API/Extensions/HostExtensions.cs new file mode 100644 index 000000000..cfa7e9d2f --- /dev/null +++ b/src/Services/Catalog/Catalog.API/Extensions/HostExtensions.cs @@ -0,0 +1,83 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using System; +using Microsoft.Extensions.DependencyInjection; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Polly; +using System.Data.SqlClient; + +namespace Catalog.API.Extensions +{ + public static class HostExtensions + { + public static bool IsInKubernetes(this IHost host) + { + var cfg = host.Services.GetService(); + var orchestratorType = cfg.GetValue("OrchestratorType"); + return orchestratorType?.ToUpper() == "K8S"; + } + + public static IHost MigrateDbContext(this IHost host, Action seeder) where TContext : DbContext + { + var underK8s = host.IsInKubernetes(); + + using (var scope = host.Services.CreateScope()) + { + var services = scope.ServiceProvider; + + var logger = services.GetRequiredService>(); + + var context = services.GetService(); + + try + { + logger.LogInformation("Migrating database associated with context {DbContextName}", typeof(TContext).Name); + + if (underK8s) + { + InvokeSeeder(seeder, context, services); + } + else + { + var retry = Policy.Handle() + .WaitAndRetry(new TimeSpan[] + { + TimeSpan.FromSeconds(3), + TimeSpan.FromSeconds(5), + TimeSpan.FromSeconds(8), + }); + + //if the sql server container is not created on run docker compose this + //migration can't fail for network related exception. The retry options for DbContext only + //apply to transient exceptions + // Note that this is NOT applied when running some orchestrators (let the orchestrator to recreate the failing service) + retry.Execute(() => InvokeSeeder(seeder, context, services)); + } + + logger.LogInformation("Migrated database associated with context {DbContextName}", typeof(TContext).Name); + } + catch (Exception ex) + { + logger.LogError(ex, "An error occurred while migrating the database used on context {DbContextName}", typeof(TContext).Name); + if (underK8s) + { + throw; // Rethrow under k8s because we rely on k8s to re-run the pod + } + } + } + + return host; + } + + private static void InvokeSeeder(Action seeder, TContext context, IServiceProvider services) + where TContext : DbContext + { + context.Database.Migrate(); + seeder(context, services); + } + } +} diff --git a/src/Services/Catalog/Catalog.API/Grpc/CatalogService.cs b/src/Services/Catalog/Catalog.API/Grpc/CatalogService.cs new file mode 100644 index 000000000..940a63c3b --- /dev/null +++ b/src/Services/Catalog/Catalog.API/Grpc/CatalogService.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using CatalogApi; +using Grpc.Core; +using Microsoft.EntityFrameworkCore; +using Microsoft.eShopOnContainers.Services.Catalog.API; +using Microsoft.eShopOnContainers.Services.Catalog.API.Infrastructure; +using Microsoft.eShopOnContainers.Services.Catalog.API.Model; +using Microsoft.Extensions.Options; +using static CatalogApi.Catalog; + +namespace Catalog.API.Grpc +{ + public class CatalogService : CatalogBase + { + private readonly CatalogContext _catalogContext; + private readonly CatalogSettings _settings; + public CatalogService(CatalogContext dbContext, IOptions settings) + { + _settings = settings.Value; + _catalogContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext)); + } + + public override async Task GetItemById(CatalogItemRequest request, ServerCallContext context) + { + + if (request.Id <=0) + { + context.Status = new Status(StatusCode.FailedPrecondition, $"Id must be > 0 (received {request.Id})"); + return null; + } + + var item = await _catalogContext.CatalogItems.SingleOrDefaultAsync(ci => ci.Id == request.Id); + var baseUri = _settings.PicBaseUrl; + var azureStorageEnabled = _settings.AzureStorageEnabled; + item.FillProductUrl(baseUri, azureStorageEnabled: azureStorageEnabled); + + if (item != null) + { + return new CatalogItemResponse() + { + AvailableStock = item.AvailableStock, + Description = item.Description, + Id = item.Id, + MaxStockThreshold = item.MaxStockThreshold, + Name = item.Name, + OnReorder = item.OnReorder, + PictureFileName = item.PictureFileName, + PictureUri = item.PictureUri, + Price = (double)item.Price, + RestockThreshold = item.RestockThreshold + }; + } + + context.Status = new Status(StatusCode.NotFound, $"Product with id {request.Id} do not exist"); + return null; + } + } +} diff --git a/src/Services/Catalog/Catalog.API/Infrastructure/CatalogContextSeed.cs b/src/Services/Catalog/Catalog.API/Infrastructure/CatalogContextSeed.cs index 8bdd2a401..ab88002ce 100644 --- a/src/Services/Catalog/Catalog.API/Infrastructure/CatalogContextSeed.cs +++ b/src/Services/Catalog/Catalog.API/Infrastructure/CatalogContextSeed.cs @@ -18,7 +18,7 @@ public class CatalogContextSeed { - public async Task SeedAsync(CatalogContext context,IHostingEnvironment env,IOptions settings,ILogger logger) + public async Task SeedAsync(CatalogContext context,IWebHostEnvironment env,IOptions settings,ILogger logger) { var policy = CreatePolicy(logger, nameof(CatalogContextSeed)); diff --git a/src/Services/Catalog/Catalog.API/Infrastructure/Filters/HttpGlobalExceptionFilter.cs b/src/Services/Catalog/Catalog.API/Infrastructure/Filters/HttpGlobalExceptionFilter.cs index 1c1dfd45f..5ddb81d63 100644 --- a/src/Services/Catalog/Catalog.API/Infrastructure/Filters/HttpGlobalExceptionFilter.cs +++ b/src/Services/Catalog/Catalog.API/Infrastructure/Filters/HttpGlobalExceptionFilter.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using System.Net; @@ -11,10 +12,10 @@ namespace Catalog.API.Infrastructure.Filters { public class HttpGlobalExceptionFilter : IExceptionFilter { - private readonly IHostingEnvironment env; + private readonly IWebHostEnvironment env; private readonly ILogger logger; - public HttpGlobalExceptionFilter(IHostingEnvironment env, ILogger logger) + public HttpGlobalExceptionFilter(IWebHostEnvironment env, ILogger logger) { this.env = env; this.logger = logger; diff --git a/src/Services/Catalog/Catalog.API/Program.cs b/src/Services/Catalog/Catalog.API/Program.cs index 39b071c46..3cd36a451 100644 --- a/src/Services/Catalog/Catalog.API/Program.cs +++ b/src/Services/Catalog/Catalog.API/Program.cs @@ -1,14 +1,21 @@ -using Microsoft.AspNetCore; +using Autofac.Extensions.DependencyInjection; +using Catalog.API.Extensions; +using Microsoft.AspNetCore; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.eShopOnContainers.BuildingBlocks.IntegrationEventLogEF; using Microsoft.eShopOnContainers.Services.Catalog.API.Infrastructure; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Serilog; using System; +using System.Collections.Generic; using System.IO; +using System.Linq; +using System.Net; namespace Microsoft.eShopOnContainers.Services.Catalog.API { @@ -26,12 +33,12 @@ namespace Microsoft.eShopOnContainers.Services.Catalog.API try { Log.Information("Configuring web host ({ApplicationContext})...", AppName); - var host = BuildWebHost(configuration, args); + var host = CreateHostBuilder(configuration, args).Build(); Log.Information("Applying migrations ({ApplicationContext})...", AppName); host.MigrateDbContext((context, services) => { - var env = services.GetService(); + var env = services.GetService(); var settings = services.GetService>(); var logger = services.GetService>(); @@ -57,16 +64,37 @@ namespace Microsoft.eShopOnContainers.Services.Catalog.API } } - private static IWebHost BuildWebHost(IConfiguration configuration, string[] args) => - WebHost.CreateDefaultBuilder(args) - .CaptureStartupErrors(false) - .UseStartup() - .UseApplicationInsights() - .UseContentRoot(Directory.GetCurrentDirectory()) - .UseWebRoot("Pics") - .UseConfiguration(configuration) - .UseSerilog() - .Build(); + + private static IHostBuilder CreateHostBuilder(IConfiguration configuration, string[] args) => + Host.CreateDefaultBuilder(args) + .ConfigureServices(services => services.AddAutofac()) + .UseServiceProviderFactory(new AutofacServiceProviderFactory()) + .ConfigureWebHostDefaults(builder => + { + builder.CaptureStartupErrors(false) + .UseConfiguration(configuration) + .ConfigureKestrel(options => + { + var ports = GetDefinedPorts(configuration); + foreach (var port in ports.Distinct()) + { + options.ListenAnyIP(port.portNumber, listenOptions => + { + Console.WriteLine($"Binding to port {port.portNumber} (https is {port.https})"); + if (port.https) + { + listenOptions.UseHttps("eshop.pfx"); + listenOptions.Protocols = HttpProtocols.Http1AndHttp2; + } + }); + } + }) + .UseStartup() + .UseApplicationInsights() + .UseContentRoot(Directory.GetCurrentDirectory()) + .UseWebRoot("Pics") + .UseSerilog(); + }); private static Serilog.ILogger CreateSerilogLogger(IConfiguration configuration) { @@ -83,6 +111,40 @@ namespace Microsoft.eShopOnContainers.Services.Catalog.API .CreateLogger(); } + private static IEnumerable<(int portNumber, bool https)> GetDefinedPorts(IConfiguration config) + { + const string https = "https://"; + const string http = "http://"; + var defport = config.GetValue("ASPNETCORE_HTTPS_PORT", 0); + if (defport != 0) + { + yield return (defport, true); + } + + var urls = config.GetValue("ASPNETCORE_URLS", null)?.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries); + if (urls?.Any() == true) + { + foreach (var urlString in urls) + { + var uri = urlString.ToLowerInvariant().Trim(); + var isHttps = uri.StartsWith(https); + var isHttp = uri.StartsWith(http); + if (!isHttp && !isHttps) + { + throw new ArgumentException($"Url {uri} must start with https:// or http://"); + } + + uri = uri.Substring(isHttps ? https.Length : http.Length); + var lastdots = uri.LastIndexOf(':'); + if (lastdots != -1) + { + var sport = uri.Substring(lastdots + 1); + yield return (int.TryParse(sport, out var nport) ? nport : isHttps ? 443 : 80, isHttps); + } + } + } + } + private static IConfiguration GetConfiguration() { var builder = new ConfigurationBuilder() @@ -103,4 +165,4 @@ namespace Microsoft.eShopOnContainers.Services.Catalog.API return builder.Build(); } } -} \ No newline at end of file +} diff --git a/src/Services/Catalog/Catalog.API/Properties/launchSettings.json b/src/Services/Catalog/Catalog.API/Properties/launchSettings.json index f842f80d0..a29630269 100644 --- a/src/Services/Catalog/Catalog.API/Properties/launchSettings.json +++ b/src/Services/Catalog/Catalog.API/Properties/launchSettings.json @@ -14,10 +14,10 @@ "launchUrl": "/swagger", "environmentVariables": { "ConnectionString": "server=localhost,5433;Database=Microsoft.eShopOnContainers.Services.CatalogDb;User Id=sa;Password=Pass@word", + "Serilog:LogstashgUrl": "http://locahost:8080", "ASPNETCORE_ENVIRONMENT": "Development", "EventBusConnection": "localhost", - "Serilog:SeqServerUrl": "http://locahost:5340", - "Serilog:LogstashgUrl":"http://locahost:8080", + "Serilog:SeqServerUrl": "http://locahost:5340" } }, "Microsoft.eShopOnContainers.Services.Catalog.API": { diff --git a/src/Services/Catalog/Catalog.API/Proto/catalog.proto b/src/Services/Catalog/Catalog.API/Proto/catalog.proto new file mode 100644 index 000000000..cee230070 --- /dev/null +++ b/src/Services/Catalog/Catalog.API/Proto/catalog.proto @@ -0,0 +1,36 @@ +syntax = "proto3"; + +package CatalogApi; + +message CatalogItemRequest { + int32 id = 1; +} + +message CatalogItemResponse { + int32 id = 1; + string name = 2; + string description=3; + double price=4; + string picture_file_name=5; + string picture_uri=6; + CatalogType catalog_type=8; + CatalogBrand catalog_brand=10; + int32 available_stock=11; + int32 restock_threshold=12; + int32 max_stock_threshold=13; + bool on_reorder=14; +} + +message CatalogBrand { + int32 id = 1; + string name = 2; +} + +message CatalogType { + int32 id = 1; + string type = 2; +} + +service Catalog { + rpc GetItemById (CatalogItemRequest) returns (CatalogItemResponse) {} +} \ No newline at end of file diff --git a/src/Services/Catalog/Catalog.API/Startup.cs b/src/Services/Catalog/Catalog.API/Startup.cs index 1a51a86fb..f81492c24 100644 --- a/src/Services/Catalog/Catalog.API/Startup.cs +++ b/src/Services/Catalog/Catalog.API/Startup.cs @@ -3,7 +3,6 @@ using Autofac.Extensions.DependencyInjection; using global::Catalog.API.Infrastructure.Filters; using global::Catalog.API.IntegrationEvents; using Microsoft.ApplicationInsights.Extensibility; -using Microsoft.ApplicationInsights.ServiceFabric; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; @@ -31,6 +30,8 @@ using System.Reflection; using HealthChecks.UI.Client; using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.Extensions.Diagnostics.HealthChecks; +using Catalog.API.Grpc; +using System.IO; namespace Microsoft.eShopOnContainers.Services.Catalog.API { @@ -43,9 +44,10 @@ namespace Microsoft.eShopOnContainers.Services.Catalog.API public IConfiguration Configuration { get; } - public IServiceProvider ConfigureServices(IServiceCollection services) + public void ConfigureServices(IServiceCollection services) { services.AddAppInsight(Configuration) + .AddGrpc().Services .AddCustomMVC(Configuration) .AddCustomDbContext(Configuration) .AddCustomOptions(Configuration) @@ -56,11 +58,10 @@ namespace Microsoft.eShopOnContainers.Services.Catalog.API var container = new ContainerBuilder(); container.Populate(services); - return new AutofacServiceProvider(container.Build()); } - public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory) + public void Configure(IApplicationBuilder app, IWebHostEnvironment env, ILoggerFactory loggerFactory) { //Configure logs @@ -88,7 +89,18 @@ namespace Microsoft.eShopOnContainers.Services.Catalog.API app.UseCors("CorsPolicy"); - app.UseMvcWithDefaultRoute(); + app.UseRouting(); + app.UseEndpoints(e => + { + e.MapDefaultControllerRoute(); + e.MapGet("/_proto/", async ctx => + { + var data = await File.ReadAllTextAsync(Path.Combine(env.ContentRootPath, "Proto", "catalog.proto")); + ctx.Response.ContentType = "text/plain"; + await ctx.Response.WriteAsync(data); + }); + e.MapGrpcService(); + }); app.UseSwagger() .UseSwaggerUI(c => @@ -119,23 +131,17 @@ namespace Microsoft.eShopOnContainers.Services.Catalog.API // Enable K8s telemetry initializer services.AddApplicationInsightsKubernetesEnricher(); } - if (orchestratorType?.ToUpper() == "SF") - { - // Enable SF telemetry initializer - services.AddSingleton((serviceProvider) => - new FabricTelemetryInitializer()); - } return services; } public static IServiceCollection AddCustomMVC(this IServiceCollection services, IConfiguration configuration) - { + { services.AddMvc(options => { options.Filters.Add(typeof(HttpGlobalExceptionFilter)); }) - .SetCompatibilityVersion(CompatibilityVersion.Version_2_2) + .SetCompatibilityVersion(CompatibilityVersion.Version_3_0) .AddControllersAsServices(); services.AddCors(options => @@ -166,7 +172,7 @@ namespace Microsoft.eShopOnContainers.Services.Catalog.API tags: new string[] { "catalogdb" }); if (!string.IsNullOrEmpty(accountName) && !string.IsNullOrEmpty(accountKey)) - { + { hcBuilder .AddAzureBlobStorage( $"DefaultEndpointsProtocol=https;AccountName={accountName};AccountKey={accountKey};EndpointSuffix=core.windows.net", @@ -256,12 +262,11 @@ namespace Microsoft.eShopOnContainers.Services.Catalog.API services.AddSwaggerGen(options => { options.DescribeAllEnumsAsStrings(); - options.SwaggerDoc("v1", new Swashbuckle.AspNetCore.Swagger.Info + options.SwaggerDoc("v1", new OpenApi.Models.OpenApiInfo { Title = "eShopOnContainers - Catalog HTTP API", Version = "v1", - Description = "The Catalog Microservice HTTP API. This is a Data-Driven/CRUD microservice sample", - TermsOfService = "Terms Of Service" + Description = "The Catalog Microservice HTTP API. This is a Data-Driven/CRUD microservice sample" }); }); diff --git a/src/Services/Catalog/Catalog.API/appsettings.Development.json b/src/Services/Catalog/Catalog.API/appsettings.Development.json new file mode 100644 index 000000000..9d8370a39 --- /dev/null +++ b/src/Services/Catalog/Catalog.API/appsettings.Development.json @@ -0,0 +1,12 @@ +{ + "Serilog": { + "MinimumLevel": { + "Default": "Debug", + "Override": { + "Microsoft": "Warning", + "Microsoft.eShopOnContainers": "Debug", + "System": "Warning" + } + } + } +} \ No newline at end of file diff --git a/src/Services/Catalog/Catalog.API/appsettings.json b/src/Services/Catalog/Catalog.API/appsettings.json index cc7b1b1fb..cc450f89d 100644 --- a/src/Services/Catalog/Catalog.API/appsettings.json +++ b/src/Services/Catalog/Catalog.API/appsettings.json @@ -20,6 +20,7 @@ "ApplicationInsights": { "InstrumentationKey": "" }, + "EventBusConnection": "localhost", "EventBusRetryCount": 5, "UseVault": false, "Vault": { @@ -28,3 +29,4 @@ "ClientSecret": "your-client-secret" } } + \ No newline at end of file diff --git a/src/Services/Catalog/Catalog.API/eshop.pfx b/src/Services/Catalog/Catalog.API/eshop.pfx new file mode 100644 index 000000000..8af82972f Binary files /dev/null and b/src/Services/Catalog/Catalog.API/eshop.pfx differ diff --git a/src/Services/Catalog/Catalog.API/web.config b/src/Services/Catalog/Catalog.API/web.config index 2157aef31..6da4550d8 100644 --- a/src/Services/Catalog/Catalog.API/web.config +++ b/src/Services/Catalog/Catalog.API/web.config @@ -4,8 +4,15 @@ - - + + + + + + + + + \ No newline at end of file