Skip to content

Commit 709b300

Browse files
committed
Add fake service and CI build/publish steps
Introduce a lightweight in-memory fake service for local/dev integration testing and update CI to build & publish its artifacts. What changed: - Added fake/ServiceTemplate.Fake project (Program.cs, endpoints, in-memory FakeStore, Dockerfile, csproj) implementing the Todos API and control endpoints for seeding, resetting, and inspecting requests. - Added charts/values-fake.yaml containing dev-focused Helm overrides (smaller resources, disabled heavy subcharts, image placeholder). - Updated .github/workflows/release.yml to build & push the fake Docker image, merge values-fake.yaml with charts/values.yaml to produce and push a service-template-fake Helm chart, and make the release job depend on the fake image/chart jobs. Why: - Provide a standalone fake service for other teams' devcontainers and for integration tests to seed state and assert interactions without depending on the real backend. - Ensure CI produces and publishes both the real and fake Docker images and Helm charts as part of the release pipeline.
1 parent 806cc7b commit 709b300

File tree

8 files changed

+396
-2
lines changed

8 files changed

+396
-2
lines changed

.github/workflows/release.yml

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,11 +91,55 @@ jobs:
9191
provenance: true
9292
sbom: true
9393

94+
# ── Build & Push Fake Docker image ──────────────────────────────────────────
95+
docker-fake:
96+
name: Build & Push Fake Docker Image
97+
runs-on: ubuntu-latest
98+
needs: test
99+
permissions:
100+
contents: read
101+
packages: write
102+
103+
steps:
104+
- uses: actions/checkout@v4
105+
106+
- name: Log in to GitHub Container Registry
107+
uses: docker/login-action@v3
108+
with:
109+
registry: ${{ env.REGISTRY }}
110+
username: ${{ github.actor }}
111+
password: ${{ secrets.GITHUB_TOKEN }}
112+
113+
- name: Docker metadata
114+
id: meta
115+
uses: docker/metadata-action@v5
116+
with:
117+
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-fake
118+
tags: |
119+
type=semver,pattern={{version}}
120+
type=semver,pattern={{major}}.{{minor}}
121+
type=semver,pattern={{major}}
122+
type=sha,prefix=sha-
123+
124+
- name: Set up Docker Buildx
125+
uses: docker/setup-buildx-action@v3
126+
127+
- name: Build and push
128+
uses: docker/build-push-action@v6
129+
with:
130+
context: fake/ServiceTemplate.Fake
131+
file: fake/ServiceTemplate.Fake/Dockerfile
132+
push: true
133+
tags: ${{ steps.meta.outputs.tags }}
134+
labels: ${{ steps.meta.outputs.labels }}
135+
cache-from: type=gha
136+
cache-to: type=gha,mode=max
137+
94138
# ── Package & Push Helm Chart ────────────────────────────────────────────────
95139
helm:
96140
name: Package & Push Helm Chart
97141
runs-on: ubuntu-latest
98-
needs: docker
142+
needs: [docker, docker-fake]
99143
permissions:
100144
contents: read
101145
packages: write
@@ -123,11 +167,25 @@ jobs:
123167
- name: Push Helm chart
124168
run: helm push *.tgz oci://${{ env.REGISTRY }}/${{ github.repository_owner }}/helm-charts
125169

170+
- name: Build fake chart
171+
run: |
172+
cp -r charts charts-fake
173+
yq eval-all 'select(fileIndex == 0) * select(fileIndex == 1)' \
174+
charts/values.yaml charts/values-fake.yaml \
175+
> charts-fake/values.yaml
176+
rm charts-fake/values-fake.yaml
177+
sed -i "s/tag: .*/tag: \"${{ steps.version.outputs.VERSION }}\"/" charts-fake/values.yaml
178+
yq -i '.name = "service-template-fake"' charts-fake/Chart.yaml
179+
helm package charts-fake --version ${{ steps.version.outputs.VERSION }} --app-version ${{ steps.version.outputs.VERSION }}
180+
181+
- name: Push fake Helm chart
182+
run: helm push service-template-fake-*.tgz oci://${{ env.REGISTRY }}/${{ github.repository_owner }}/helm-charts
183+
126184
# ── Create GitHub Release ────────────────────────────────────────────────────
127185
release:
128186
name: Create GitHub Release
129187
runs-on: ubuntu-latest
130-
needs: [docker, helm]
188+
needs: [docker, docker-fake, helm]
131189
permissions:
132190
contents: write
133191

charts/values-fake.yaml

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Fake (local dev) overrides — consumed by other teams' devcontainers.
2+
# CI merges these over values.yaml to produce the service-template-fake helm chart.
3+
# Consuming teams reference service-template-fake and need zero configuration.
4+
5+
image:
6+
repository: ghcr.io/your-org/service-template-fake
7+
tag: "" # overridden by CI with the release tag
8+
pullPolicy: Always
9+
10+
replicaCount: 1
11+
12+
# No external secrets needed — fake has no DB
13+
envFromSecret:
14+
enabled: false
15+
16+
# Disable heavy subcharts for local dev
17+
postgresql:
18+
enabled: false
19+
20+
opentelemetry-collector:
21+
enabled: false
22+
23+
resources:
24+
requests:
25+
cpu: 10m
26+
memory: 32Mi
27+
limits:
28+
cpu: 200m
29+
memory: 128Mi
30+
31+
autoscaling:
32+
enabled: false
33+
34+
podDisruptionBudget:
35+
enabled: false
36+
37+
topologySpreadConstraints: []
38+
39+
affinity: {}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
2+
WORKDIR /app
3+
EXPOSE 8080
4+
ENV ASPNETCORE_URLS=http://+:8080
5+
ENV ASPNETCORE_ENVIRONMENT=Production
6+
7+
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
8+
WORKDIR /src
9+
COPY ["ServiceTemplate.Fake.csproj", "."]
10+
RUN dotnet restore
11+
COPY . .
12+
RUN dotnet publish -c Release -o /app/publish --no-restore
13+
14+
FROM base AS final
15+
WORKDIR /app
16+
COPY --from=build /app/publish .
17+
ENTRYPOINT ["dotnet", "ServiceTemplate.Fake.dll"]
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
namespace ServiceTemplate.Fake.Endpoints;
2+
3+
internal static class FakeControlEndpoints
4+
{
5+
public static IEndpointRouteBuilder MapFakeControlEndpoints(this IEndpointRouteBuilder app)
6+
{
7+
var group = app.MapGroup("/fake").WithTags("fake-control");
8+
9+
// Seed todos directly — useful for setting up test state without going through the API
10+
// POST /fake/todos
11+
group.MapPost("/todos", (SeedTodoRequest req, FakeStore store) =>
12+
{
13+
var todo = store.Add(req.Title, req.Description, req.DueDate);
14+
return Results.Ok(TodoResponse.From(todo));
15+
});
16+
17+
// Return all requests received since last reset.
18+
// Use this to assert that your service called the fake with the right data.
19+
// GET /fake/requests
20+
group.MapGet("/requests", (FakeStore store) =>
21+
Results.Ok(store.GetRecordedRequests()));
22+
23+
// Clear all todos and recorded requests.
24+
// Call between test scenarios to ensure a clean slate.
25+
// POST /fake/reset
26+
group.MapPost("/reset", (FakeStore store) =>
27+
{
28+
store.Reset();
29+
return Results.NoContent();
30+
});
31+
32+
return app;
33+
}
34+
}
35+
36+
internal sealed record SeedTodoRequest(
37+
string Title,
38+
string? Description = null,
39+
DateTimeOffset? DueDate = null);
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
using Microsoft.AspNetCore.Mvc;
2+
3+
namespace ServiceTemplate.Fake.Endpoints;
4+
5+
internal static class TodoEndpoints
6+
{
7+
public static IEndpointRouteBuilder MapTodoEndpoints(this IEndpointRouteBuilder app)
8+
{
9+
var group = app.MapGroup("/api/todos").WithTags("Todos");
10+
11+
group.MapGet("/", GetTodosAsync)
12+
.WithName("GetTodos");
13+
14+
group.MapGet("/{id:guid}", GetTodoAsync)
15+
.WithName("GetTodo")
16+
.Produces<TodoResponse>()
17+
.ProducesProblem(StatusCodes.Status404NotFound);
18+
19+
group.MapPost("/", CreateTodoAsync)
20+
.WithName("CreateTodo")
21+
.Produces<TodoResponse>(StatusCodes.Status201Created);
22+
23+
group.MapPut("/{id:guid}", UpdateTodoAsync)
24+
.WithName("UpdateTodo")
25+
.Produces<TodoResponse>()
26+
.ProducesProblem(StatusCodes.Status404NotFound);
27+
28+
group.MapDelete("/{id:guid}", DeleteTodoAsync)
29+
.WithName("DeleteTodo")
30+
.Produces(StatusCodes.Status204NoContent)
31+
.ProducesProblem(StatusCodes.Status404NotFound);
32+
33+
return app;
34+
}
35+
36+
private static IResult GetTodosAsync(
37+
[FromQuery] int page,
38+
[FromQuery] int pageSize,
39+
FakeStore store,
40+
HttpContext ctx)
41+
{
42+
store.Record(new RecordedRequest("GET", ctx.Request.Path, null, DateTimeOffset.UtcNow));
43+
44+
var all = store.GetAll();
45+
var items = all
46+
.Skip((page - 1) * pageSize)
47+
.Take(pageSize)
48+
.Select(TodoResponse.From)
49+
.ToList();
50+
51+
return Results.Ok(new PagedResult<TodoResponse>(items, all.Count, page, pageSize));
52+
}
53+
54+
private static IResult GetTodoAsync(Guid id, FakeStore store, HttpContext ctx)
55+
{
56+
store.Record(new RecordedRequest("GET", ctx.Request.Path, null, DateTimeOffset.UtcNow));
57+
58+
var todo = store.Get(id);
59+
return todo is null
60+
? Results.Problem(detail: $"Todo {id} not found", statusCode: StatusCodes.Status404NotFound)
61+
: Results.Ok(TodoResponse.From(todo));
62+
}
63+
64+
private static async Task<IResult> CreateTodoAsync(
65+
CreateTodoRequest request,
66+
FakeStore store,
67+
HttpContext ctx)
68+
{
69+
store.Record(new RecordedRequest("POST", ctx.Request.Path,
70+
await ReadBodyAsync(ctx), DateTimeOffset.UtcNow));
71+
72+
var todo = store.Add(request.Title, request.Description, request.DueDate);
73+
return Results.CreatedAtRoute("GetTodo", new { id = todo.Id }, TodoResponse.From(todo));
74+
}
75+
76+
private static async Task<IResult> UpdateTodoAsync(
77+
Guid id,
78+
[FromBody] UpdateTodoRequest request,
79+
FakeStore store,
80+
HttpContext ctx)
81+
{
82+
store.Record(new RecordedRequest("PUT", ctx.Request.Path,
83+
await ReadBodyAsync(ctx), DateTimeOffset.UtcNow));
84+
85+
var todo = store.Update(id, request.Title, request.Description, request.DueDate);
86+
return todo is null
87+
? Results.Problem(detail: $"Todo {id} not found", statusCode: StatusCodes.Status404NotFound)
88+
: Results.Ok(TodoResponse.From(todo));
89+
}
90+
91+
private static IResult DeleteTodoAsync(Guid id, FakeStore store, HttpContext ctx)
92+
{
93+
store.Record(new RecordedRequest("DELETE", ctx.Request.Path, null, DateTimeOffset.UtcNow));
94+
95+
return store.Delete(id)
96+
? Results.NoContent()
97+
: Results.Problem(detail: $"Todo {id} not found", statusCode: StatusCodes.Status404NotFound);
98+
}
99+
100+
private static async Task<string?> ReadBodyAsync(HttpContext ctx)
101+
{
102+
ctx.Request.EnableBuffering();
103+
using var reader = new StreamReader(ctx.Request.Body, leaveOpen: true);
104+
var body = await reader.ReadToEndAsync();
105+
ctx.Request.Body.Position = 0;
106+
return body;
107+
}
108+
}
109+
110+
// Mirror the real contract shapes — no dependency on Application project
111+
internal sealed record TodoResponse(
112+
Guid Id,
113+
string Title,
114+
string? Description,
115+
string Status,
116+
DateTimeOffset? DueDate,
117+
DateTimeOffset CreatedAt,
118+
DateTimeOffset UpdatedAt)
119+
{
120+
public static TodoResponse From(FakeTodo t) =>
121+
new(t.Id, t.Title, t.Description, t.Status, t.DueDate, t.CreatedAt, t.UpdatedAt);
122+
}
123+
124+
internal sealed record PagedResult<T>(
125+
IReadOnlyList<T> Items,
126+
int TotalCount,
127+
int Page,
128+
int PageSize)
129+
{
130+
public int TotalPages => (int)Math.Ceiling((double)TotalCount / PageSize);
131+
public bool HasPreviousPage => Page > 1;
132+
public bool HasNextPage => Page < TotalPages;
133+
}
134+
135+
internal sealed record CreateTodoRequest(string Title, string? Description, DateTimeOffset? DueDate);
136+
internal sealed record UpdateTodoRequest(string Title, string? Description, DateTimeOffset? DueDate);
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
using System.Collections.Concurrent;
2+
3+
namespace ServiceTemplate.Fake.Endpoints;
4+
5+
/// <summary>
6+
/// In-memory store for the fake service.
7+
/// Holds todos and a log of every request received.
8+
/// Thread-safe — safe for concurrent test calls.
9+
/// </summary>
10+
public sealed class FakeStore
11+
{
12+
private readonly ConcurrentDictionary<Guid, FakeTodo> _todos = new();
13+
private readonly ConcurrentQueue<RecordedRequest> _requests = new();
14+
15+
// ── Todos ─────────────────────────────────────────────────────────────────
16+
17+
public FakeTodo Add(string title, string? description, DateTimeOffset? dueDate)
18+
{
19+
var todo = new FakeTodo(Guid.NewGuid(), title, description, "Pending", dueDate,
20+
DateTimeOffset.UtcNow, DateTimeOffset.UtcNow);
21+
_todos[todo.Id] = todo;
22+
return todo;
23+
}
24+
25+
public FakeTodo? Get(Guid id) =>
26+
_todos.GetValueOrDefault(id);
27+
28+
public IReadOnlyList<FakeTodo> GetAll() =>
29+
[.. _todos.Values.OrderBy(t => t.CreatedAt)];
30+
31+
public FakeTodo? Update(Guid id, string title, string? description, DateTimeOffset? dueDate)
32+
{
33+
if (!_todos.TryGetValue(id, out var existing)) return null;
34+
var updated = existing with
35+
{
36+
Title = title,
37+
Description = description,
38+
DueDate = dueDate,
39+
UpdatedAt = DateTimeOffset.UtcNow
40+
};
41+
_todos[id] = updated;
42+
return updated;
43+
}
44+
45+
public bool Delete(Guid id) =>
46+
_todos.TryRemove(id, out _);
47+
48+
// ── Request log ───────────────────────────────────────────────────────────
49+
50+
public void Record(RecordedRequest request) =>
51+
_requests.Enqueue(request);
52+
53+
public IReadOnlyList<RecordedRequest> GetRecordedRequests() =>
54+
_requests.ToArray();
55+
56+
// ── Reset ─────────────────────────────────────────────────────────────────
57+
58+
public void Reset()
59+
{
60+
_todos.Clear();
61+
while (_requests.TryDequeue(out _)) { }
62+
}
63+
}
64+
65+
public sealed record FakeTodo(
66+
Guid Id,
67+
string Title,
68+
string? Description,
69+
string Status,
70+
DateTimeOffset? DueDate,
71+
DateTimeOffset CreatedAt,
72+
DateTimeOffset UpdatedAt);
73+
74+
public sealed record RecordedRequest(
75+
string Method,
76+
string Path,
77+
string? Body,
78+
DateTimeOffset ReceivedAt);

0 commit comments

Comments
 (0)