diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..03c9b93 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +bin/ +obj/ +.vs/ diff --git a/README.md b/README.md index 1718ce1..69623d2 100644 --- a/README.md +++ b/README.md @@ -1,47 +1,13 @@ -# Docker Image +# Learn DevOps -Docker images are created using dockerfiles. A sample dockerfile, from which -you can create docker images, is given at the root of the repository. This -sample dockerfile will be used in the demo section. +This is a hands-on learning repository where we explore and experiment with +DevOps tools, technologies, and best practices. -## Create image with dockerfile +Each topic is organized in its own directory with dedicated documentation and +examples. -To build a docker image, run `docker build . ` at the directory your -dockerfile is in. This searches for a dockerfile in the run directory and -creates a docker image from found dockerfile. You can give name to docker image -with `-t {image-name}`, `docker build -t {image-name} . ` +## What We've Learned So Far -## Run image in container - -`docker run`, creates an instance of docker image and runs it in a container. -Adding args at the end of the run command will run those arguments, thus you -can pass commands as arguments. - -`docker run {image-name} args` - -## Persisting Data - -To bind a host directory to container, use `docker run` with -`-v {absolutepath}:{containerpath}`. - -`docker run -v {absolutepath}:{containerpath} {image-name} args` - -Changes made in this folder by the container persists, you can observe these -changes in host folder. To get the output of the container in host folder, set -the output as the container folder, which is bound to host folder. - -`docker run --rm -v ${PWD}/host-output:/container-output args {-output} /container-output` - -## Reaching Localhost from Container - -To reach your machine's localhost from the container, when giving the url use -`host.docker.internal` instead of localhost. - -`http://host.docker.internal:{port}`. - -## Demo - -Dockerfile at the root of this repository wraps -[web-ping](https://github.com/SeriaWei/Ping). You can create a docker -image from this dockerfile and use this cli in your container by passing -`web-ping Web.Ping --host https://github.com/` command as argument. \ No newline at end of file +- [Docker](docker/README.md): Docker images, Dockerfiles, and container basics +- [Keycloak](keycloak/README.md): Identity management, JWT authentication, + Docker Compose setup diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 0000000..1718ce1 --- /dev/null +++ b/docker/README.md @@ -0,0 +1,47 @@ +# Docker Image + +Docker images are created using dockerfiles. A sample dockerfile, from which +you can create docker images, is given at the root of the repository. This +sample dockerfile will be used in the demo section. + +## Create image with dockerfile + +To build a docker image, run `docker build . ` at the directory your +dockerfile is in. This searches for a dockerfile in the run directory and +creates a docker image from found dockerfile. You can give name to docker image +with `-t {image-name}`, `docker build -t {image-name} . ` + +## Run image in container + +`docker run`, creates an instance of docker image and runs it in a container. +Adding args at the end of the run command will run those arguments, thus you +can pass commands as arguments. + +`docker run {image-name} args` + +## Persisting Data + +To bind a host directory to container, use `docker run` with +`-v {absolutepath}:{containerpath}`. + +`docker run -v {absolutepath}:{containerpath} {image-name} args` + +Changes made in this folder by the container persists, you can observe these +changes in host folder. To get the output of the container in host folder, set +the output as the container folder, which is bound to host folder. + +`docker run --rm -v ${PWD}/host-output:/container-output args {-output} /container-output` + +## Reaching Localhost from Container + +To reach your machine's localhost from the container, when giving the url use +`host.docker.internal` instead of localhost. + +`http://host.docker.internal:{port}`. + +## Demo + +Dockerfile at the root of this repository wraps +[web-ping](https://github.com/SeriaWei/Ping). You can create a docker +image from this dockerfile and use this cli in your container by passing +`web-ping Web.Ping --host https://github.com/` command as argument. \ No newline at end of file diff --git a/dockerfile b/docker/dockerfile similarity index 97% rename from dockerfile rename to docker/dockerfile index c7311ee..f009e49 100644 --- a/dockerfile +++ b/docker/dockerfile @@ -1,5 +1,5 @@ -# Parent image -FROM mcr.microsoft.com/dotnet/sdk:6.0 - -RUN dotnet tool install --global Web.Ping --version 0.0.4 +# Parent image +FROM mcr.microsoft.com/dotnet/sdk:6.0 + +RUN dotnet tool install --global Web.Ping --version 0.0.4 ENV PATH="/root/.dotnet/tools:${PATH}" \ No newline at end of file diff --git a/keycloak/README.md b/keycloak/README.md new file mode 100644 index 0000000..0a2bc4e --- /dev/null +++ b/keycloak/README.md @@ -0,0 +1,169 @@ +# Keycloak + +This documentation covers using Keycloak with Docker in this repo. + +## Configuration + +Keycloak can be configured in four ways: + +1. Command-line parameters +2. Environment variables +3. Options in `conf/keycloak.conf` (or a user-provided config file) +4. Sensitive options in a Java KeyStore + +We use the config file approach. Options follow the format +`=`. + +- Default config path: `conf/keycloak.conf` +- Environment placeholders: `${ENV_VAR}` with optional fallback + `${ENV_VAR:default}` + +All available options: [All Configs] + +Note: Some realm settings are restricted at runtime; enabling flags (e.g., +`spi-admin-allowed-system-variables`) should be used cautiously. + +### Database + +You can configure the database via the config file, environment variables, or +CLI flags. Precedence: CLI > environment > config file. + +Using `keycloak.conf`: + +``` +db-url-host=mykeycloakdb +``` + +### Quarkus framework + +For gaps in Keycloak options, you can fall back to raw Quarkus properties: +[Quarkus Properties] + +## Import realms + +Keycloak creates a `master` realm by default; avoid using it for applications. +You can create realms via the UI or Admin API, or import a prebuilt JSON by +copying it into the image under `/opt/keycloak/data/import/` and starting with +realm import enabled. + +## Modes + +Keycloak runs in development (default) and production modes. Some features +differ: [Dev Mode] + +### Production mode + +Production requires additional setup: + +- HTTP disabled; HTTPS (TLS) required +- Hostname configuration required +- HTTPS/TLS configuration required + +## Optimizations + +For faster startup in containers, use the recommended flow: + +1. Build once normally +2. Start with `--optimized` to reuse the build + +If runtime build config conflicts with a pre-build, the pre-built assets take +precedence. + +## UI + +Access the Admin Console at `{base-url}/admin` to manage realms, users, and +settings. + +## API + +Manage Keycloak via the Admin REST API under `{base-url}/admin`. +Example: `POST /admin/realms/{realm}/logout-all` +API reference: [API Reference] + +OpenID Connect discovery: [OIDC Discovery] + +## Realms + +Realms isolate users and configuration. The `master` realm exists by default and +should be used only for administering Keycloak. + +## Token + +Use Protocol Mappers to add claims to tokens. For example, adding an audience +claim uses the `oidc-audience-mapper`. See `keycloak/realm-config.json` in this +repo for basic examples. + +## Login and Redirect + +To start the login flow, send a GET request to: + +``` +GET http://localhost:8080/realms//protocol/openid-connect/auth +``` + +Query parameters: +- `client_id`: The client ID (e.g. weather-api) +- `response_type`: `code` (for Authorization Code Flow) +- `scope`: `openid` (and any additional scopes) +- `redirect_uri`: Where the user will be redirected after login + (e.g. http://localhost) +- `prompt`: (optional) Controls whether the login screen is shown. Use + `prompt=none` to attempt silent authentication (no UI); if the user is not + already logged in, an error is returned instead of showing the login page. + This is useful for checking session status or implementing silent SSO. + +Request: +```url +http://localhost:8080/realms/test-realm/protocol/openid-connect/auth + ?client_id=weather-api + &response_type=code + &scope=openid + &redirect_uri=http://localhost + &prompt=none +``` + +After successful login, Keycloak redirects to: +``` +http://localhost/?code=AUTH_CODE&session_state=...&iss=... +``` + +To exchange the `code` for an access token, send a POST request to: +``` +POST http://localhost:8080/realms//protocol/openid-connect/token +Content-Type: application/x-www-form-urlencoded + +client_id=weather-api +&grant_type=authorization_code +&code=AUTH_CODE +&redirect_uri=http://localhost +``` + +#### Redirect URI Settings + +For the redirect to work, the client must have: +- `redirectUris`: Allowed redirect URIs (e.g. ["http://localhost/*"]) +- `webOrigins`: Allowed CORS origins (e.g. ["http://localhost"]) +- `standardFlowEnabled`: true (required for Authorization Code Flow) + +> [Info] +> +> If you want to obtain tokens directly from a frontend (SPA/JS) app, set +> `publicClient: true` and do not send `client_secret` in the token request. For +> confidential clients (`publicClient: false`), using the secret in the frontend +> is insecure and required by Keycloak. + +These settings must be present in both JSON imports and in the Keycloak UI +client configuration. + +## Docker + +For production, size memory appropriately. Guidance: [Sizing Guide] + +## References + +[All Configs]: https://www.keycloak.org/server/all-config?f=build +[Quarkus Properties]: https://www.keycloak.org/server/configuration#_format_for_raw_quarkus_properties +[Dev Mode]: https://www.keycloak.org/server/configuration#_starting_keycloak_in_development_mode +[API Reference]: https://www.keycloak.org/docs-api/latest/rest-api/index.html +[OIDC Discovery]: http://localhost:8080/realms/master/.well-known/openid-configuration +[Sizing Guide]: https://www.keycloak.org/high-availability/single-cluster/concepts-memory-and-cpu-sizing#single-cluster-single-site-calculation \ No newline at end of file diff --git a/keycloak/Ui/index.html b/keycloak/Ui/index.html new file mode 100644 index 0000000..da4d1c9 --- /dev/null +++ b/keycloak/Ui/index.html @@ -0,0 +1,97 @@ + + + + + Keycloak Flow Demo + + + +

Weather App

+ +
Click "Load Weather" to start
+ + + + \ No newline at end of file diff --git a/keycloak/Ui/nginx.conf b/keycloak/Ui/nginx.conf new file mode 100644 index 0000000..710645b --- /dev/null +++ b/keycloak/Ui/nginx.conf @@ -0,0 +1,17 @@ +events {} +http { + server { + listen 80; + + root /usr/share/nginx/; + + location / { + try_files $uri /index.html; + } + + location /weather { + proxy_pass http://weather-service:8000/; + proxy_set_header Host $host; + } + } +} \ No newline at end of file diff --git a/keycloak/WeatherService/Dockerfile b/keycloak/WeatherService/Dockerfile new file mode 100644 index 0000000..bec0640 --- /dev/null +++ b/keycloak/WeatherService/Dockerfile @@ -0,0 +1,21 @@ +# AI Generated + +# Build stage +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build +WORKDIR /src + +# Copy csproj and restore dependencies +COPY WeatherService.csproj . +RUN dotnet restore + +# Copy everything else and build +COPY . . +RUN dotnet publish -c Release -o /app/publish + +# Runtime stage +FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime +WORKDIR /app +COPY --from=build /app/publish . + +EXPOSE 8080 +ENTRYPOINT ["dotnet", "WeatherService.dll"] diff --git a/keycloak/WeatherService/JwtTokenBuilder.cs b/keycloak/WeatherService/JwtTokenBuilder.cs new file mode 100644 index 0000000..3e9e534 --- /dev/null +++ b/keycloak/WeatherService/JwtTokenBuilder.cs @@ -0,0 +1,35 @@ +using Microsoft.IdentityModel.Tokens; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; + +namespace WeatherService; + +public class JwtTokenBuilder(IConfiguration _configuration, TimeProvider _timeProvider) +{ + readonly int _defaultExpiresInMinutes = 20; + + string Key => _configuration.GetValue($"Authentication:Jwt:{nameof(Key)}", "7F9aP2LkQxM4WJtE8RZsD0HnYcB5U3Vv"); + string? Issuer => _configuration.GetValue($"Authentication:Jwt:{nameof(Issuer)}"); + string? Audience => _configuration.GetValue($"Authentication:Jwt:{nameof(Audience)}"); + + public string Build(List claims) + { + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Key)); + var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + Console.WriteLine($"Building JWT for Issuer: {Issuer}, Audience: {Audience}"); + + var token = new JwtSecurityToken( + issuer: Issuer, + audience: Audience, + claims: claims, + notBefore: DateTime.UtcNow, + expires: DateTime.UtcNow.AddMinutes(_defaultExpiresInMinutes), + signingCredentials: creds + ); + var res = new JwtSecurityTokenHandler().WriteToken(token); + Console.WriteLine($"ress {res}"); + return res; + } +} \ No newline at end of file diff --git a/keycloak/WeatherService/KeycloakService.cs b/keycloak/WeatherService/KeycloakService.cs new file mode 100644 index 0000000..7324405 --- /dev/null +++ b/keycloak/WeatherService/KeycloakService.cs @@ -0,0 +1,32 @@ +using System.Text.Json; + +namespace WeatherService; + +public class KeycloakClient(IConfiguration _configuration, HttpClient _httpClient) +{ + private readonly HttpClient _httpClient = _httpClient; + private readonly string _tokenEndpoint = "http://keycloak:8080/realms/test-realm/protocol/openid-connect/token"; + private readonly string _clientId = "weather-api"; + private readonly string _clientSecret = _configuration["Keycloak:ClientSecret"] ?? "clientSecret"; + + public async Task GetTokenByCodeAsync(string code, string redirectUri) + { + var parameters = new Dictionary + { + { "grant_type", "authorization_code" }, + { "code", code }, + { "client_id", _clientId }, + { "client_secret", _clientSecret }, + { "redirect_uri", redirectUri } + }; + + var content = new FormUrlEncodedContent(parameters); + var response = await _httpClient.PostAsync(_tokenEndpoint, content); + response.EnsureSuccessStatusCode(); + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + var accessToken = doc.RootElement.GetProperty("access_token").GetString(); + + return accessToken; + } +} \ No newline at end of file diff --git a/keycloak/WeatherService/LoginRequest.cs b/keycloak/WeatherService/LoginRequest.cs new file mode 100644 index 0000000..60b2d2a --- /dev/null +++ b/keycloak/WeatherService/LoginRequest.cs @@ -0,0 +1,3 @@ +namespace WeatherService; + +public record LoginRequestBody(string Code, string RedirectUri); diff --git a/keycloak/WeatherService/LoginResponse.cs b/keycloak/WeatherService/LoginResponse.cs new file mode 100644 index 0000000..97254f7 --- /dev/null +++ b/keycloak/WeatherService/LoginResponse.cs @@ -0,0 +1,3 @@ +namespace WeatherService; + +public record LoginResponse(string AccessToken); \ No newline at end of file diff --git a/keycloak/WeatherService/Program.cs b/keycloak/WeatherService/Program.cs new file mode 100644 index 0000000..67e8228 --- /dev/null +++ b/keycloak/WeatherService/Program.cs @@ -0,0 +1,97 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Mvc; +using Microsoft.IdentityModel.Tokens; +using System.Security.Claims; +using System.Text; +using WeatherService; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddCors(options => +{ + options.AddPolicy("AllowAll", policy => + { + policy.AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader(); + }); +}); + +builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuers = [ + "http://localhost:80", + "http://localhost", + ], + ValidateAudience = true, + ValidAudience = builder.Configuration["Authentication:Jwt:Audience"], + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey( + Encoding.UTF8.GetBytes( + builder.Configuration["Authentication:Jwt:Key"]! + ) + ), + + ValidateLifetime = true, + ClockSkew = TimeSpan.Zero + }; + options.Events = new JwtBearerEvents + { + OnAuthenticationFailed = ctx => + { + Console.WriteLine("Auth failed: " + ctx.Exception); + return Task.CompletedTask; + }, + OnTokenValidated = ctx => + { + Console.WriteLine("Token validated successfully"); + return Task.CompletedTask; + } + }; + }); +builder.Services.AddAuthorization(); + +builder.Services.AddSingleton(); + +builder.Services.AddHttpClient(); + +var app = builder.Build(); + +app.UseCors("AllowAll"); + +app.UseAuthentication(); +app.UseAuthorization(); + +app.MapGet("/weather", () => +{ + var random = new Random(); + var weather = new WeatherResponse + { + Temperature = random.Next(-10, 40), + Sky = random.Next(100) < 30 ? "Rainy" : "Sunny" + }; + + return Results.Ok(weather); +}).RequireAuthorization(); +app.MapPost("/login-by-code", async ([FromServices] JwtTokenBuilder tokenBuilder, [FromServices] KeycloakClient keycloakClient, [FromBody] LoginRequestBody body) => +{ + var accessToken = await keycloakClient.GetTokenByCodeAsync(body.Code, body.RedirectUri); + var handler = new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler(); + var jwt = handler.ReadJwtToken(accessToken); + var username = jwt.Claims.FirstOrDefault(c => c.Type == "username" || c.Type == "preferred_username")?.Value; + + List claims = []; + if (!string.IsNullOrEmpty(username)) + { + claims.Add(new Claim(ClaimTypes.Name, username)); + } + + string token = tokenBuilder.Build(claims); + return Results.Ok(new LoginResponse(AccessToken: token)); +}).AllowAnonymous(); + +app.Run(); \ No newline at end of file diff --git a/keycloak/WeatherService/Properties/launchSettings.json b/keycloak/WeatherService/Properties/launchSettings.json new file mode 100644 index 0000000..3bd7c37 --- /dev/null +++ b/keycloak/WeatherService/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5275", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7132;http://localhost:5275", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/keycloak/WeatherService/WeatherResponse.cs b/keycloak/WeatherService/WeatherResponse.cs new file mode 100644 index 0000000..264df5c --- /dev/null +++ b/keycloak/WeatherService/WeatherResponse.cs @@ -0,0 +1,7 @@ +namespace WeatherService; + +public record WeatherResponse +{ + public int Temperature { get; init; } + public string? Sky { get; init; } +} \ No newline at end of file diff --git a/keycloak/WeatherService/WeatherService.csproj b/keycloak/WeatherService/WeatherService.csproj new file mode 100644 index 0000000..7941d0b --- /dev/null +++ b/keycloak/WeatherService/WeatherService.csproj @@ -0,0 +1,13 @@ + + + + net9.0 + enable + enable + + + + + + + diff --git a/keycloak/WeatherService/appsettings.Development.json b/keycloak/WeatherService/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/keycloak/WeatherService/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/keycloak/WeatherService/appsettings.json b/keycloak/WeatherService/appsettings.json new file mode 100644 index 0000000..d129936 --- /dev/null +++ b/keycloak/WeatherService/appsettings.json @@ -0,0 +1,20 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Authentication": { + "Jwt": { + "Authority": "http://localhost:80", + "Audience": "weather-api", + "Key": "7F9aP2LkQxM4WJtE8RZsD0HnYcB5U3Vv", + "Issuer": "http://localhost:80" + } + }, + "Keycloak": { + "ClientSecret": "clientSecret" + } +} diff --git a/keycloak/compose.yml b/keycloak/compose.yml new file mode 100644 index 0000000..34b6abb --- /dev/null +++ b/keycloak/compose.yml @@ -0,0 +1,72 @@ +services: + postgres: + image: postgres:16 + container_name: keycloak-db + environment: + POSTGRES_DB: keycloak + POSTGRES_USER: keycloak + POSTGRES_PASSWORD: keycloak + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD", "pg_isready", "-U", "keycloak"] + interval: 5s + timeout: 5s + retries: 5 + networks: + - keycloak-network + + keycloak: + build: + context: ./keycloak + dockerfile: Dockerfile + container_name: keycloak + environment: + - KEYCLOAK_ADMIN=admin + - KEYCLOAK_ADMIN_PASSWORD=admin + - KC_DB_PASSWORD=keycloak + ports: + - "8080:8080" + depends_on: + postgres: + condition: service_healthy + networks: + - keycloak-network + + weather-service: + build: + context: ./WeatherService + dockerfile: Dockerfile + container_name: weather-service + ports: + - "5000:8000" + environment: + - ASPNETCORE_ENVIRONMENT=Development + - ASPNETCORE_HTTP_PORTS=8000 + - ASPNETCORE_URLS=http://+:8000 + depends_on: + - keycloak + networks: + - keycloak-network + + nginx: + image: nginx:alpine + ports: + - "80:80" + volumes: + - ./Ui/nginx.conf:/etc/nginx/nginx.conf:ro + - ./Ui/index.html:/usr/share/nginx/index.html:ro + depends_on: + - weather-service + - keycloak + networks: + - keycloak-network + +volumes: + postgres_data: + +networks: + keycloak-network: + driver: bridge \ No newline at end of file diff --git a/keycloak/keycloak/Dockerfile b/keycloak/keycloak/Dockerfile new file mode 100644 index 0000000..abefe05 --- /dev/null +++ b/keycloak/keycloak/Dockerfile @@ -0,0 +1,6 @@ +FROM quay.io/keycloak/keycloak:latest + +COPY keycloak.conf /opt/keycloak/conf/keycloak.conf + +ENTRYPOINT ["/opt/keycloak/bin/kc.sh"] +CMD ["start-dev"] diff --git a/keycloak/keycloak/keycloak.conf b/keycloak/keycloak/keycloak.conf new file mode 100644 index 0000000..5e3304f --- /dev/null +++ b/keycloak/keycloak/keycloak.conf @@ -0,0 +1,14 @@ +# Database +db=postgres +db-url-host=postgres +db-url-database=keycloak +db-username=keycloak +db-password=${KC_DB_PASSWORD:keycloak} + +# HTTP +http-enabled=true +http-port=8080 +hostname-strict=false + +# Health +health-enabled=true diff --git a/learn-devops.sln b/learn-devops.sln new file mode 100644 index 0000000..1c5bbc4 --- /dev/null +++ b/learn-devops.sln @@ -0,0 +1,29 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "keycloak", "keycloak", "{4B5D3AB7-A3DA-2315-2A6C-49C09F1422D9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WeatherService", "keycloak\WeatherService\WeatherService.csproj", "{71188429-1219-F9F0-2787-95DF822AD803}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {71188429-1219-F9F0-2787-95DF822AD803}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {71188429-1219-F9F0-2787-95DF822AD803}.Debug|Any CPU.Build.0 = Debug|Any CPU + {71188429-1219-F9F0-2787-95DF822AD803}.Release|Any CPU.ActiveCfg = Release|Any CPU + {71188429-1219-F9F0-2787-95DF822AD803}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {71188429-1219-F9F0-2787-95DF822AD803} = {4B5D3AB7-A3DA-2315-2A6C-49C09F1422D9} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {224833B9-0692-448C-812F-BA26384CFE31} + EndGlobalSection +EndGlobal