Merge pull request 'Adding locations micro-service' (#3) from locations-microservice into main
All checks were successful
Deploy Promiscuity Auth API / deploy (push) Successful in 1m16s
Deploy Promiscuity Character API / deploy (push) Successful in 1m15s
Deploy Promiscuity Locations API / deploy (push) Successful in 1m14s
k8s smoke test / test (push) Successful in 9s

Reviewed-on: #3
This commit is contained in:
admin 2026-01-20 16:48:32 -06:00
commit eb34e6692f
105 changed files with 4344 additions and 3724 deletions

View File

@ -82,6 +82,15 @@ jobs:
mkdir -p /tmp/kube mkdir -p /tmp/kube
printf '%s\n' "$KUBECONFIG_CONTENT" > /tmp/kube/config printf '%s\n' "$KUBECONFIG_CONTENT" > /tmp/kube/config
# -----------------------------
# Ensure namespace exists
# -----------------------------
- name: Create namespace if missing
env:
KUBECONFIG: /tmp/kube/config
run: |
kubectl create namespace promiscuity-auth --dry-run=client -o yaml | kubectl apply -f -
# ----------------------------- # -----------------------------
# Apply Kubernetes manifests # Apply Kubernetes manifests
# (You create these files in your repo) # (You create these files in your repo)

View File

@ -82,6 +82,15 @@ jobs:
mkdir -p /tmp/kube mkdir -p /tmp/kube
printf '%s\n' "$KUBECONFIG_CONTENT" > /tmp/kube/config printf '%s\n' "$KUBECONFIG_CONTENT" > /tmp/kube/config
# -----------------------------
# Ensure namespace exists
# -----------------------------
- name: Create namespace if missing
env:
KUBECONFIG: /tmp/kube/config
run: |
kubectl create namespace promiscuity-character --dry-run=client -o yaml | kubectl apply -f -
# ----------------------------- # -----------------------------
# Apply Kubernetes manifests # Apply Kubernetes manifests
# ----------------------------- # -----------------------------

View File

@ -0,0 +1,112 @@
name: Deploy Promiscuity Locations API
on:
push:
branches:
- main
workflow_dispatch: {}
jobs:
deploy:
runs-on: self-hosted
env:
IMAGE_NAME: promiscuity-locations:latest
IMAGE_TAR: /tmp/promiscuity-locations.tar
# All nodes that might run the pod (control-plane + workers)
NODES: "192.168.86.72 192.168.86.73 192.168.86.74"
steps:
- name: Checkout repo
uses: actions/checkout@v4
# -----------------------------
# Build Docker image
# -----------------------------
- name: Build Docker image
run: |
cd microservices/LocationsApi
docker build -t "${IMAGE_NAME}" .
# -----------------------------
# Save image as TAR on runner
# -----------------------------
- name: Save Docker image to TAR
run: |
docker save "${IMAGE_NAME}" -o "${IMAGE_TAR}"
# -----------------------------
# Copy TAR to each Kubernetes node
# -----------------------------
- name: Copy TAR to nodes
run: |
for node in ${NODES}; do
echo "Copying image tar to $node ..."
scp -o StrictHostKeyChecking=no "${IMAGE_TAR}" hz@"$node":/tmp/promiscuity-locations.tar
done
# -----------------------------
# Import image into containerd on each node
# -----------------------------
- name: Import image on nodes
run: |
for node in ${NODES}; do
echo "Importing image on $node ..."
ssh -o StrictHostKeyChecking=no hz@"$node" "sudo ctr -n k8s.io images import /tmp/promiscuity-locations.tar"
done
# -----------------------------
# CLEANUP: delete TAR from nodes
# -----------------------------
- name: Clean TAR from nodes
run: |
for node in ${NODES}; do
echo "Removing image tar on $node ..."
ssh -o StrictHostKeyChecking=no hz@"$node" "rm -f /tmp/promiscuity-locations.tar"
done
# -----------------------------
# CLEANUP: delete TAR from runner
# -----------------------------
- name: Clean TAR on runner
run: |
rm -f "${IMAGE_TAR}"
# -----------------------------
# Write kubeconfig from secret
# -----------------------------
- name: Write kubeconfig from secret
env:
KUBECONFIG_CONTENT: ${{ secrets.KUBECONFIG }}
run: |
mkdir -p /tmp/kube
printf '%s\n' "$KUBECONFIG_CONTENT" > /tmp/kube/config
# -----------------------------
# Ensure namespace exists
# -----------------------------
- name: Create namespace if missing
env:
KUBECONFIG: /tmp/kube/config
run: |
kubectl create namespace promiscuity-locations --dry-run=client -o yaml | kubectl apply -f -
# -----------------------------
# Apply Kubernetes manifests
# -----------------------------
- name: Apply Locations deployment & service
env:
KUBECONFIG: /tmp/kube/config
run: |
kubectl apply -f microservices/LocationsApi/k8s/deployment.yaml -n promiscuity-locations
kubectl apply -f microservices/LocationsApi/k8s/service.yaml -n promiscuity-locations
# -----------------------------
# Rollout restart & wait
# -----------------------------
- name: Restart Locations deployment
env:
KUBECONFIG: /tmp/kube/config
run: |
kubectl rollout restart deployment/promiscuity-locations -n promiscuity-locations
kubectl rollout status deployment/promiscuity-locations -n promiscuity-locations

12
README.md Normal file
View File

@ -0,0 +1,12 @@
# Promiscuity
## Microservices
- Auth Microservice Swagger: https://pauth.ranaze.com/swagger/index.html
- Character Microservice Swagger: https://pchar.ranaze.com/swagger/index.html
- Microservices README: microservices/README.md
## Test users
- `SUPER/SUPER` - Super User
- `test1/test1` - Super User
- `test3/test3` - User

View File

@ -1,8 +0,0 @@
Auth Microservice swagger is accessible at https://pauth.ranaze.com/swagger/index.html
Character Microservice swagger is accessible at https://pchar.ranaze.com/swagger/index.html
Test Users:
SUPER/SUPER - Super User
test1/test1 - Super User
test3/test3 - User

View File

@ -0,0 +1,49 @@
# AuthApi document shapes
This service expects JSON request bodies for its auth endpoints and stores user
documents in MongoDB.
Inbound JSON documents
- RegisterRequest (`POST /api/auth/register`)
```json
{
"username": "string",
"password": "string",
"email": "string (optional)"
}
```
- LoginRequest (`POST /api/auth/login`)
```json
{
"username": "string",
"password": "string"
}
```
- RefreshRequest (`POST /api/auth/refresh`)
```json
{
"username": "string",
"refreshToken": "string"
}
```
- ChangeRoleRequest (`POST /api/auth/role`)
```json
{
"username": "string",
"newRole": "USER | SUPER"
}
```
Stored documents (MongoDB)
- User
```json
{
"id": "string (ObjectId)",
"username": "string",
"passwordHash": "string",
"role": "USER | SUPER",
"email": "string (optional)",
"refreshToken": "string (optional)",
"refreshTokenExpiry": "string (optional, ISO-8601 datetime)"
}
```

View File

@ -0,0 +1,12 @@
# AuthApi
## Document shapes
See `DOCUMENTS.md` for request payloads and stored document shapes.
## Endpoints
- `POST /api/auth/register` Register a new user.
- `POST /api/auth/login` Issue access and refresh tokens.
- `POST /api/auth/refresh` Refresh an access token.
- `POST /api/auth/logout` Revoke the current access token.
- `POST /api/auth/role` Update a user's role (SUPER only).
- `GET /api/auth/users` List users (SUPER only).

View File

@ -0,0 +1,23 @@
# CharacterApi document shapes
This service expects JSON request bodies for character creation and stores
character documents in MongoDB.
Inbound JSON documents
- CreateCharacterRequest (`POST /api/characters`)
```json
{
"name": "string"
}
```
Stored documents (MongoDB)
- Character
```json
{
"id": "string (ObjectId)",
"ownerUserId": "string",
"name": "string",
"createdUtc": "string (ISO-8601 datetime)"
}
```

View File

@ -0,0 +1,9 @@
# CharacterApi
## Document shapes
See `DOCUMENTS.md` for request payloads and stored document shapes.
## Endpoints
- `POST /api/characters` Create a character.
- `GET /api/characters` List characters for the current user.
- `DELETE /api/characters/{id}` Delete a character owned by the current user.

View File

@ -0,0 +1,83 @@
using LocationsApi.Models;
using LocationsApi.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
namespace LocationsApi.Controllers;
[ApiController]
[Route("api/[controller]")]
public class LocationsController : ControllerBase
{
private readonly LocationStore _locations;
public LocationsController(LocationStore locations)
{
_locations = locations;
}
[HttpPost]
[Authorize(Roles = "SUPER")]
public async Task<IActionResult> Create([FromBody] CreateLocationRequest req)
{
if (string.IsNullOrWhiteSpace(req.Name))
return BadRequest("Name required");
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrWhiteSpace(userId))
return Unauthorized();
var location = new Location
{
OwnerUserId = userId,
Name = req.Name.Trim(),
CreatedUtc = DateTime.UtcNow
};
await _locations.CreateAsync(location);
return Ok(location);
}
[HttpGet]
[Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> ListMine()
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrWhiteSpace(userId))
return Unauthorized();
var locations = await _locations.GetForOwnerAsync(userId);
return Ok(locations);
}
[HttpDelete("{id}")]
[Authorize(Roles = "SUPER")]
public async Task<IActionResult> Delete(string id)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrWhiteSpace(userId))
return Unauthorized();
var allowAnyOwner = User.IsInRole("SUPER");
var deleted = await _locations.DeleteForOwnerAsync(id, userId, allowAnyOwner);
if (!deleted)
return NotFound();
return Ok("Deleted");
}
[HttpPut("{id}")]
[Authorize(Roles = "SUPER")]
public async Task<IActionResult> Update(string id, [FromBody] UpdateLocationRequest req)
{
if (string.IsNullOrWhiteSpace(req.Name))
return BadRequest("Name required");
var updated = await _locations.UpdateNameAsync(id, req.Name.Trim());
if (!updated)
return NotFound();
return Ok("Updated");
}
}

View File

@ -0,0 +1,29 @@
# LocationsApi document shapes
This service expects JSON request bodies for location creation and updates and
stores location documents in MongoDB.
Inbound JSON documents
- CreateLocationRequest (`POST /api/locations`)
```json
{
"name": "string"
}
```
- UpdateLocationRequest (`PUT /api/locations/{id}`)
```json
{
"name": "string"
}
```
Stored documents (MongoDB)
- Location
```json
{
"id": "string (ObjectId)",
"ownerUserId": "string",
"name": "string",
"createdUtc": "string (ISO-8601 datetime)"
}
```

View File

@ -0,0 +1,21 @@
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
# Copy project file first to take advantage of Docker layer caching
COPY ["LocationsApi.csproj", "./"]
RUN dotnet restore "LocationsApi.csproj"
# Copy the remaining source and publish
COPY . .
RUN dotnet publish "LocationsApi.csproj" -c Release -o /app/publish /p:UseAppHost=false
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
WORKDIR /app
COPY --from=build /app/publish .
ENV ASPNETCORE_URLS=http://+:8080 \
ASPNETCORE_ENVIRONMENT=Production
EXPOSE 8080
ENTRYPOINT ["dotnet", "LocationsApi.dll"]

View File

@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.8" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.8" />
<PackageReference Include="MongoDB.Driver" Version="3.4.3" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,6 @@
namespace LocationsApi.Models;
public class CreateLocationRequest
{
public string Name { get; set; } = string.Empty;
}

View File

@ -0,0 +1,17 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace LocationsApi.Models;
public class Location
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string? Id { get; set; }
public string OwnerUserId { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
}

View File

@ -0,0 +1,6 @@
namespace LocationsApi.Models;
public class UpdateLocationRequest
{
public string Name { get; set; } = string.Empty;
}

View File

@ -0,0 +1,72 @@
using LocationsApi.Services;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi.Models;
using System.Text;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
// DI
builder.Services.AddSingleton<LocationStore>();
// Swagger + JWT auth in Swagger
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo { Title = "Locations API", Version = "v1" });
c.AddSecurityDefinition("bearerAuth", new OpenApiSecurityScheme
{
Type = SecuritySchemeType.Http,
Scheme = "bearer",
BearerFormat = "JWT",
Description = "Paste your access token here (no 'Bearer ' prefix needed)."
});
c.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{ Type = ReferenceType.SecurityScheme, Id = "bearerAuth" }
},
Array.Empty<string>()
}
});
});
// AuthN/JWT
var cfg = builder.Configuration;
var jwtKey = cfg["Jwt:Key"] ?? throw new Exception("Jwt:Key missing");
var issuer = cfg["Jwt:Issuer"] ?? "promiscuity";
var aud = cfg["Jwt:Audience"] ?? issuer;
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(o =>
{
o.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true, ValidIssuer = issuer,
ValidateAudience = true, ValidAudience = aud,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)),
ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(30)
};
});
builder.Services.AddAuthorization();
var app = builder.Build();
app.MapGet("/healthz", () => Results.Ok("ok"));
app.UseSwagger();
app.UseSwaggerUI(o =>
{
o.SwaggerEndpoint("/swagger/v1/swagger.json", "Locations API v1");
o.RoutePrefix = "swagger";
});
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();

View File

@ -0,0 +1,12 @@
{
"profiles": {
"LocationsApi": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:50786;http://localhost:50787"
}
}
}

View File

@ -0,0 +1,10 @@
# LocationsApi
## Document shapes
See `DOCUMENTS.md` for request payloads and stored document shapes.
## Endpoints
- `POST /api/locations` Create a location (SUPER only).
- `GET /api/locations` List locations for the current user.
- `DELETE /api/locations/{id}` Delete a location (SUPER only).
- `PUT /api/locations/{id}` Update a location name (SUPER only).

View File

@ -0,0 +1,49 @@
using LocationsApi.Models;
using MongoDB.Driver;
namespace LocationsApi.Services;
public class LocationStore
{
private readonly IMongoCollection<Location> _col;
public LocationStore(IConfiguration cfg)
{
var cs = cfg["MongoDB:ConnectionString"] ?? "mongodb://127.0.0.1:27017";
var dbName = cfg["MongoDB:DatabaseName"] ?? "GameDb";
var client = new MongoClient(cs);
var db = client.GetDatabase(dbName);
_col = db.GetCollection<Location>("Locations");
var ownerIndex = Builders<Location>.IndexKeys.Ascending(l => l.OwnerUserId);
_col.Indexes.CreateOne(new CreateIndexModel<Location>(ownerIndex));
}
public Task CreateAsync(Location location) => _col.InsertOneAsync(location);
public Task<List<Location>> GetForOwnerAsync(string ownerUserId) =>
_col.Find(l => l.OwnerUserId == ownerUserId).ToListAsync();
public async Task<bool> DeleteForOwnerAsync(string id, string ownerUserId, bool allowAnyOwner)
{
var filter = Builders<Location>.Filter.Eq(l => l.Id, id);
if (!allowAnyOwner)
{
filter = Builders<Location>.Filter.And(
filter,
Builders<Location>.Filter.Eq(l => l.OwnerUserId, ownerUserId)
);
}
var result = await _col.DeleteOneAsync(filter);
return result.DeletedCount > 0;
}
public async Task<bool> UpdateNameAsync(string id, string name)
{
var filter = Builders<Location>.Filter.Eq(l => l.Id, id);
var update = Builders<Location>.Update.Set(l => l.Name, name);
var result = await _col.UpdateOneAsync(filter, update);
return result.ModifiedCount > 0;
}
}

View File

@ -0,0 +1,6 @@
{
"Kestrel": { "Endpoints": { "Http": { "Url": "http://0.0.0.0:5002" } } },
"MongoDB": { "ConnectionString": "mongodb://192.168.86.50:27017", "DatabaseName": "promiscuity" },
"Jwt": { "Key": "SuperUltraSecureJwtKeyWithAtLeast32Chars!!", "Issuer": "promiscuity", "Audience": "promiscuity-auth-api" },
"Logging": { "LogLevel": { "Default": "Information" } }
}

View File

@ -0,0 +1,7 @@
{
"Kestrel": { "Endpoints": { "Http": { "Url": "http://0.0.0.0:5002" } } },
"MongoDB": { "ConnectionString": "mongodb://192.168.86.50:27017", "DatabaseName": "promiscuity" },
"Jwt": { "Key": "SuperUltraSecureJwtKeyWithAtLeast32Chars!!", "Issuer": "promiscuity", "Audience": "promiscuity-auth-api" },
"Logging": { "LogLevel": { "Default": "Information" } },
"AllowedHosts": "*"
}

View File

@ -0,0 +1,28 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: promiscuity-locations
labels:
app: promiscuity-locations
spec:
replicas: 2
selector:
matchLabels:
app: promiscuity-locations
template:
metadata:
labels:
app: promiscuity-locations
spec:
containers:
- name: promiscuity-locations
image: promiscuity-locations:latest
imagePullPolicy: IfNotPresent
ports:
- containerPort: 5002
readinessProbe:
httpGet:
path: /healthz
port: 5002
initialDelaySeconds: 5
periodSeconds: 10

View File

@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: promiscuity-locations
labels:
app: promiscuity-locations
spec:
selector:
app: promiscuity-locations
type: NodePort
ports:
- name: http
port: 80 # cluster port
targetPort: 5002 # container port
nodePort: 30082 # external port

View File

@ -1,2 +1,12 @@
# micro-services # micro-services
## Document shapes
- AuthApi: `AuthApi/DOCUMENTS.md` (auth request payloads and user document shape)
- CharacterApi: `CharacterApi/DOCUMENTS.md` (character create payload and stored document)
- LocationsApi: `LocationsApi/DOCUMENTS.md` (location create/update payloads and stored document)
## Service READMEs
- AuthApi: `AuthApi/README.md`
- CharacterApi: `CharacterApi/README.md`
- LocationsApi: `LocationsApi/README.md`

View File

@ -6,6 +6,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AuthApi", "AuthApi\AuthApi.
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CharacterApi", "CharacterApi\CharacterApi.csproj", "{1572BA36-8EFC-4472-BE74-0676B593AED9}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CharacterApi", "CharacterApi\CharacterApi.csproj", "{1572BA36-8EFC-4472-BE74-0676B593AED9}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LocationsApi", "LocationsApi\LocationsApi.csproj", "{C343AFFB-9AB0-4B70-834C-3D2A21E2B506}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@ -20,6 +22,10 @@ Global
{1572BA36-8EFC-4472-BE74-0676B593AED9}.Debug|Any CPU.Build.0 = Debug|Any CPU {1572BA36-8EFC-4472-BE74-0676B593AED9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1572BA36-8EFC-4472-BE74-0676B593AED9}.Release|Any CPU.ActiveCfg = Release|Any CPU {1572BA36-8EFC-4472-BE74-0676B593AED9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1572BA36-8EFC-4472-BE74-0676B593AED9}.Release|Any CPU.Build.0 = Release|Any CPU {1572BA36-8EFC-4472-BE74-0676B593AED9}.Release|Any CPU.Build.0 = Release|Any CPU
{C343AFFB-9AB0-4B70-834C-3D2A21E2B506}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C343AFFB-9AB0-4B70-834C-3D2A21E2B506}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C343AFFB-9AB0-4B70-834C-3D2A21E2B506}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C343AFFB-9AB0-4B70-834C-3D2A21E2B506}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE