9 Commits

Author SHA1 Message Date
claude 1920885835 Merge main into production: PR #55 — Fix CV keyword extraction prompt
Build and Push Docker Images Staging / build (push) Successful in 52s
2026-06-09 16:41:03 +03:00
claude eb83d28ed5 Merge staging into production: PR #54 — Fix hardcoded user-facing strings
Build and Push Docker Images Staging / build (push) Successful in 10m36s
2026-06-08 22:34:35 +03:00
claude b6d9aea3bc Merge branch 'main' into production
Build and Push Docker Images Staging / build (push) Successful in 1m5s
2026-06-08 22:10:26 +03:00
claude 2b9132a3a9 Merge branch 'main' into production
Build and Push Docker Images Staging / build (push) Failing after 14s
2026-06-08 22:08:32 +03:00
claude e5bf56cc4d Merge branch 'main' into production
Build and Push Docker Images Staging / build (push) Successful in 42s
2026-06-08 21:48:24 +03:00
claude 8f58708cd9 Revert "Suppress environment prefix in email subjects on Production"
Build and Push Docker Images Staging / build (push) Successful in 1m35s
This reverts commit 06dd0140d6.
2026-06-08 21:45:45 +03:00
claude 06dd0140d6 Suppress environment prefix in email subjects on Production
[ENV_NAME] prefix is now only prepended in non-production environments
(Development, Staging, etc.). Production emails get a clean subject line.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 21:45:29 +03:00
claude 0aee7c4ed6 Changes
Build and Push Docker Images Staging / build (push) Successful in 45s
2026-06-08 21:31:35 +03:00
claude cd661fe613 Merge pull request 'Staging to Production' (#51) from main into production
Merge staging to production
2026-06-08 18:28:46 +00:00
12 changed files with 45 additions and 182 deletions
+6 -110
View File
@@ -1,19 +1,13 @@
name: Build and Push Docker Images
name: Build and Push Docker Images Staging
# Branch-driven deploys — no yaml edits to switch environment:
# merge into `staging` -> tag :staging (staging Watchtower deploys)
# merge into `production` -> tag :production (production Watchtower deploys)
# `main` is the day-to-day work branch and deploys nothing.
on:
push:
branches:
- staging
- production
env:
GIT_HOST: docker-git.easysoft.ro
GIT_HOST: git.easysoft.ro
REGISTRY_HOST: registry.easysoft.ro
DOCKER_BUILDKIT: "1"
API_IMAGE: apps/myai-api
CV_MATCHER_API_IMAGE: apps/myai-cv-matcher-api
RAG_API_IMAGE: apps/myai-rag-api
@@ -22,20 +16,18 @@ env:
CV_CLEANUP_JOB_IMAGE: apps/myai-cv-cleanup-job
CV_SEARCH_JOB_IMAGE: apps/myai-cv-search-job
PAGE_FETCHER_API_IMAGE: apps/myai-page-fetcher-api
IMAGE_TAG: ${{ github.ref_name }} # branch name == image tag (staging | production)
WEB_PORT: "5140" # host port the web container is published on
IMAGE_TAG: production
jobs:
build:
runs-on: host
steps:
- name: Checkout the pushed commit
- name: Checkout repository
env:
TOKEN: ${{ secrets.REPO_TOKEN }}
run: |
git clone "http://gelu:${TOKEN}@${GIT_HOST}:3000/${GITHUB_REPOSITORY}.git" .
git checkout "${{ github.sha }}"
- name: Login to registry
run: |
@@ -61,8 +53,7 @@ jobs:
- name: Build Web image
run: |
docker build --build-arg GIT_SHA="${{ github.sha }}" \
-f web/Dockerfile -t "${REGISTRY_HOST}/${WEB_IMAGE}:${IMAGE_TAG}" .
docker build -f web/Dockerfile -t "${REGISTRY_HOST}/${WEB_IMAGE}:${IMAGE_TAG}" .
- name: Build CV cleanup job image
run: |
@@ -106,99 +97,4 @@ jobs:
- name: Push Page Fetcher API image
run: |
docker push "${REGISTRY_HOST}/${PAGE_FETCHER_API_IMAGE}:${IMAGE_TAG}"
# Watchtower's poll is the fallback, not the mechanism: 30s on staging but 300s on
# production, so without this a green run can sit five minutes ahead of the deploy it
# claims to have made. Copied from easyDent, including the soft failure -- an unset
# secret or an unreachable API must degrade to the poll, never fail the build.
- name: Trigger Watchtower redeploy
env:
URL_STAGING: ${{ secrets.WATCHTOWER_URL_STAGING }}
URL_PRODUCTION: ${{ secrets.WATCHTOWER_URL_PRODUCTION }}
TOKEN: ${{ secrets.WATCHTOWER_TOKEN }}
run: |
if [ "${IMAGE_TAG}" = "production" ]; then URL="${URL_PRODUCTION}"; else URL="${URL_STAGING}"; fi
if [ -n "${URL}" ] && [ -n "${TOKEN}" ]; then
echo "Triggering Watchtower at ${URL}"
curl -sf -m 30 -H "Authorization: Bearer ${TOKEN}" "${URL}" && echo " -> redeploy triggered" \
|| echo " -> trigger failed; Watchtower will still pick it up on the next poll"
else
echo "Watchtower push-trigger not configured (WATCHTOWER_* secrets unset); relying on the poll interval."
fi
- name: Reclaim disk space (keep recent build cache)
if: always()
run: |
docker image prune -f # dangling only (keep base images)
# Building and pushing an image proves nothing about what the host is running.
# Watchtower pulls asynchronously, and for a month it was pulling a tag nobody
# intended -- with every run green, because no step ever asked the deployed site
# what it was serving. This job asks.
#
# It polls the deploy host directly on the LAN rather than the public hostname:
# the runner sits inside the network, only easysoft.ro has a staging equivalent in
# public DNS, and going direct also takes Caddy and any CDN out of the answer.
smoke:
runs-on: host
needs: build
steps:
- name: Wait for the deploy host to serve this commit
run: |
case "${{ github.ref_name }}" in
staging) HOST=192.168.1.111 ;;
production) HOST=192.168.1.101 ;;
*) echo "::error::No deploy host mapped for '${{ github.ref_name }}'."; exit 1 ;;
esac
URL="http://${HOST}:${WEB_PORT}/version.json"
echo "Polling ${URL} for ${{ github.sha }}"
# 10 minutes: Watchtower's poke is fire-and-forget with a 30s fallback poll,
# and the container still has to start.
# ⚠️ Steps run under `bash -e -o pipefail`, so a polling loop has to be written
# defensively: the FIRST miss is the normal case, not an error.
# - `curl -sf | sed` fails the whole pipeline under pipefail while the old
# container is still up (404/connection refused), so `|| GOT=""` is required
# - `[ test ] && { ... }` returns non-zero when the test fails, which under -e
# aborts the step. Use `if`.
# Getting both wrong made the first run of this job fail in 20 seconds.
DEADLINE=$(( $(date +%s) + 600 ))
while :; do
GOT=$(curl -sf -m 15 "${URL}" 2>/dev/null | sed -n 's/.*"version":"\([^"]*\)".*/\1/p') || GOT=""
if [ "${GOT}" = "${{ github.sha }}" ]; then
echo "Serving ${GOT}."
break
fi
if [ "$(date +%s)" -ge "${DEADLINE}" ]; then
echo "::error::Timed out after 10m. ${HOST} is serving '${GOT:-nothing}', wanted ${{ github.sha }}."
echo "Either Watchtower never pulled the new image, the container failed to"
echo "start, or the stack's IMAGE_TAG does not match this branch."
exit 1
fi
echo " still serving '${GOT:-nothing}' ..."
sleep 15
done
- name: Check the site actually answers
run: |
case "${{ github.ref_name }}" in
staging) HOST=192.168.1.111 ;;
production) HOST=192.168.1.101 ;;
esac
# `-L` follows redirects and we assert on the FINAL code, because a 302 from `/`
# is a healthy answer for a site running in UnderConstruction mode -- it means the
# app is up and routing. Asserting a bare 200 failed jecreativ.ro's first
# production deploy for doing exactly what it was configured to do.
#
# `|| CODE=000` for the same reason as above: curl exiting non-zero on a
# connection failure must produce a reportable code, not kill the step before
# it can say what went wrong. (`-s` without `-f` already tolerates 4xx/5xx.)
CODE=$(curl -sL -o /dev/null -w '%{http_code}' -m 20 "http://${HOST}:${WEB_PORT}/") || CODE=000
if [ "${CODE}" != "200" ]; then
echo "::error::Home page returned ${CODE}."
exit 1
fi
echo "Home page 200."
docker push "${REGISTRY_HOST}/${PAGE_FETCHER_API_IMAGE}:${IMAGE_TAG}"
-3
View File
@@ -376,6 +376,3 @@ files/
/docker-compose/.env.production
/docker-compose/.env.staging
# local infra access notes (secrets) — never commit
ACCESS.md
+1 -5
View File
@@ -34,15 +34,11 @@ This applies to both the staging and production repos as appropriate.
- .NET 10, ASP.NET Core, Worker Service
- Entity Framework Core + SQL Server (multi-schema)
- Refit for typed HTTP clients between services
- Serilog — Compact JSON logs to stdout (+ optional email sink); see Observability
- Serilog (JSON structured logging, Console + File + Email sinks)
- MailKit for SMTP (used exclusively in `email-api`)
- Docker Compose for local and production deployment
- Watchtower for automatic container updates in production
## Observability (central stack on monitoring host 10.0.0.156)
- **Logs**: every service uses `ConfigureJsonSerilog(ServiceName, appVersion)` (startup-helpers) → Serilog **Compact JSON** to stdout, enriched `Application`/`Environment`/`AppVersion`. The host's Grafana **Alloy** agent ships stdout → **Loki**; view/query in Grafana. No file sink; optional email sink only if `SerilogEmail:*` is configured.
- **No app metrics/traces** — these are simple/minimal services, so (unlike easyDent) they don't expose Prometheus metrics or OTLP traces. Container/host metrics still come from the host's cAdvisor/node_exporter.
## Project taxonomy
| Category | Naming | Contains | EF dependency |
-1
View File
@@ -23,7 +23,6 @@
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageVersion Include="Serilog.Enrichers.Environment" Version="3.0.1" />
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageVersion Include="Serilog.Formatting.Compact" Version="3.0.0" />
<PackageVersion Include="Serilog.Sinks.Email" Version="4.2.1" />
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
<!-- Swagger -->
+8 -6
View File
@@ -39,10 +39,11 @@ public static class StartupExtensions
.ReadFrom.Configuration(context.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext()
.Enrich.WithProperty("Application", serviceName)
.Enrich.WithProperty("Environment", context.HostingEnvironment.EnvironmentName)
.Enrich.WithMachineName()
.Enrich.WithEnvironmentName()
.Enrich.WithProperty("Service", serviceName)
.Enrich.WithProperty("AppVersion", appVersion)
.WriteTo.Console(new Serilog.Formatting.Compact.CompactJsonFormatter());
.WriteTo.Console(new Serilog.Formatting.Json.JsonFormatter());
AddEmailSinkIfConfigured(configuration, context.Configuration, serviceName);
});
@@ -56,10 +57,11 @@ public static class StartupExtensions
.ReadFrom.Configuration(builder.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext()
.Enrich.WithProperty("Application", serviceName)
.Enrich.WithProperty("Environment", builder.Environment.EnvironmentName)
.Enrich.WithMachineName()
.Enrich.WithEnvironmentName()
.Enrich.WithProperty("Service", serviceName)
.Enrich.WithProperty("AppVersion", appVersion)
.WriteTo.Console(new Serilog.Formatting.Compact.CompactJsonFormatter());
.WriteTo.Console(new Serilog.Formatting.Json.JsonFormatter());
AddEmailSinkIfConfigured(configuration, builder.Configuration, serviceName);
});
@@ -17,7 +17,6 @@
<PackageReference Include="DotNetEnv" />
<PackageReference Include="Serilog.AspNetCore" />
<PackageReference Include="Serilog.Enrichers.Environment" />
<PackageReference Include="Serilog.Formatting.Compact" />
<PackageReference Include="Serilog.Sinks.Email" />
<PackageReference Include="Serilog.Sinks.File" />
<PackageReference Include="Swashbuckle.AspNetCore" />
+16 -23
View File
@@ -1,27 +1,20 @@
# myAi
# Introduction
TODO: Give a short introduction of your project. Let this section explain the objectives or the motivation behind this project.
The **myai.ro** platform — a set of .NET microservices (CV matching, RAG, email, CV search, page
fetching, …) behind a web frontend + API. Part of the easySoft platform.
# Getting Started
TODO: Guide users through getting your code up and running on their own system. In this section you can talk about:
1. Installation process
2. Software dependencies
3. Latest releases
4. API references
## Layout
Multiple services (`*-api`, `*-job`) + `web`, sharing a common bootstrap in
`startup-helpers/` (Serilog, Swagger, `.env`/Key Vault loading, middleware). See **CLAUDE.md**
for the full service map, dependency chain, and conventions.
# Build and Test
TODO: Describe and show how to build your code and run the tests.
## Run locally
```bash
docker compose up --build # or run individual services with: dotnet run --project <svc>
```
# Contribute
TODO: Explain how other users and developers can contribute to make your code better.
## Deploy
CI builds `registry.easysoft.ro/apps/myai-*:{staging,production}`; Watchtower rolls them out to
the **staging (`10.0.0.183`)** + **production (`10.0.0.248`)** Portainer stacks. Edge Caddy serves
**myai.ro** (prod) / **myai.easysoft.ro** (staging).
## Logging
Every service: `ConfigureJsonSerilog(name, version)` → Serilog **Compact JSON** to stdout → Grafana
**Alloy****Loki**. No app metrics/traces (simple services).
---
See **CLAUDE.md** for the detailed solution guide and **ACCESS.md** (local, gitignored) for
infrastructure access.
If you want to learn more about creating good readme files then refer the following [guidelines](https://docs.microsoft.com/en-us/azure/devops/repos/git/create-a-readme?view=azure-devops). You can also seek inspiration from the below readme files:
- [ASP.NET Core](https://github.com/aspnet/Home)
- [Visual Studio Code](https://github.com/Microsoft/vscode)
- [Chakra Core](https://github.com/Microsoft/ChakraCore)
+2 -4
View File
@@ -49,10 +49,8 @@ Ai__Ollama__ChatModel=llama2
Ai__Ollama__EmbeddingModel=embedding-model
Ai__Ollama__TimeoutSeconds=30
# Database (shared) - maps to Database:Host etc. used by apps.
# Deployed (staging/prod) uses the LAN DNS name (resolves to the MSSQL VM 10.0.0.240);
# for local dev leave it unset to use the docker-compose 'sqlserver' service default.
Database__Host=mssql.easysoft.ro
# Database (shared) - maps to Database:Host etc. used by apps
Database__Host=sqlserver
Database__Port=1433
Database__Name=MyAiDb
Database__User=sa
+8 -16
View File
@@ -1,14 +1,6 @@
# ⚠️ The IMAGE_TAG fallback is a DELIBERATELY INVALID tag, not `staging`.
# On 2026-07-26 these stacks were recreated by hand and lost their environment
# variables. The old `${IMAGE_TAG:-staging}` then quietly resolved to `staging`, so the
# production host pulled staging images -- with no mail credentials and no recipient
# addresses -- and served them for a month. Nothing failed, because falling back to a
# real tag is indistinguishable from being configured. Now an unset IMAGE_TAG yields
# `IMAGE_TAG-NOT-SET`, the pull fails with "manifest not found", the running container
# is left untouched and the deploy goes red. Loud beats plausible.
services:
rag-api:
image: registry.easysoft.ro/apps/myai-rag-api:${IMAGE_TAG:-IMAGE_TAG-NOT-SET}
image: registry.easysoft.ro/apps/myai-rag-api:${IMAGE_TAG:-staging}
container_name: myai-rag-api
environment:
- ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging}
@@ -58,7 +50,7 @@ services:
- "com.centurylinklabs.watchtower.enable=true"
cv-matcher-api:
image: registry.easysoft.ro/apps/myai-cv-matcher-api:${IMAGE_TAG:-IMAGE_TAG-NOT-SET}
image: registry.easysoft.ro/apps/myai-cv-matcher-api:${IMAGE_TAG:-staging}
container_name: myai-cv-matcher-api
depends_on:
- rag-api
@@ -110,7 +102,7 @@ services:
- "com.centurylinklabs.watchtower.enable=true"
email-api:
image: registry.easysoft.ro/apps/myai-email-api:${IMAGE_TAG:-IMAGE_TAG-NOT-SET}
image: registry.easysoft.ro/apps/myai-email-api:${IMAGE_TAG:-staging}
container_name: myai-email-api
environment:
- ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging}
@@ -151,7 +143,7 @@ services:
- "com.centurylinklabs.watchtower.enable=true"
api:
image: registry.easysoft.ro/apps/myai-api:${IMAGE_TAG:-IMAGE_TAG-NOT-SET}
image: registry.easysoft.ro/apps/myai-api:${IMAGE_TAG:-staging}
container_name: myai-api
depends_on:
- cv-matcher-api
@@ -225,7 +217,7 @@ services:
- "com.centurylinklabs.watchtower.enable=true"
cv-cleanup-job:
image: registry.easysoft.ro/apps/myai-cv-cleanup-job:${IMAGE_TAG:-IMAGE_TAG-NOT-SET}
image: registry.easysoft.ro/apps/myai-cv-cleanup-job:${IMAGE_TAG:-staging}
container_name: myai-cv-cleanup-job
depends_on:
- api
@@ -255,7 +247,7 @@ services:
- "com.centurylinklabs.watchtower.enable=true"
cv-search-job:
image: registry.easysoft.ro/apps/myai-cv-search-job:${IMAGE_TAG:-IMAGE_TAG-NOT-SET}
image: registry.easysoft.ro/apps/myai-cv-search-job:${IMAGE_TAG:-staging}
container_name: myai-cv-search-job
depends_on:
- cv-matcher-api
@@ -308,7 +300,7 @@ services:
- "com.centurylinklabs.watchtower.enable=true"
page-fetcher-api:
image: registry.easysoft.ro/apps/myai-page-fetcher-api:${IMAGE_TAG:-IMAGE_TAG-NOT-SET}
image: registry.easysoft.ro/apps/myai-page-fetcher-api:${IMAGE_TAG:-staging}
container_name: myai-page-fetcher-api
environment:
- ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging}
@@ -340,7 +332,7 @@ services:
- "com.centurylinklabs.watchtower.enable=true"
web:
image: registry.easysoft.ro/apps/myai-web:${IMAGE_TAG:-IMAGE_TAG-NOT-SET}
image: registry.easysoft.ro/apps/myai-web:${IMAGE_TAG:-staging}
container_name: myai-web
depends_on:
- api
-9
View File
@@ -16,13 +16,4 @@ EXPOSE 8080
ENV ASPNETCORE_URLS=http://0.0.0.0:8080
COPY --from=build /app/publish .
# Stamp the commit into the image so a deploy can be verified from the outside.
#
# Without this a smoke test can only ask "does the site return 200?" -- which it did
# throughout the month production was quietly serving staging images. A status check
# cannot tell one build from another; /version.json can, so the smoke job refuses to
# pass until the host is actually serving THIS commit.
ARG GIT_SHA=unknown
RUN mkdir -p wwwroot && printf '{"version":"%s"}' "$GIT_SHA" > wwwroot/version.json
ENTRYPOINT ["dotnet", "web.dll"]
+2 -2
View File
@@ -139,13 +139,13 @@
<div>
<span data-i18n="contact.phone">Phone</span>
<strong>
<a href="tel:+40774885011">+40 774-885-011</a>
<a href="tel:+40722523764">+40 722-523-764</a>
</strong>
</div>
<div>
<span>WhatsApp</span>
<strong>
<a href="https://wa.me/40774885011" target="_blank" rel="noreferrer">+40 774-885-011</a>
<a href="https://wa.me/40722523764" target="_blank" rel="noreferrer">+40 722-523-764</a>
</strong>
</div>
+2 -2
View File
@@ -126,13 +126,13 @@
<div>
<span data-i18n="contact.phone">Phone</span>
<strong>
<a href="tel:+40774885011">+40 774-885-011</a>
<a href="tel:+40722523764">+40 722-523-764</a>
</strong>
</div>
<div>
<span>WhatsApp</span>
<strong>
<a href="https://wa.me/40774885011" target="_blank" rel="noreferrer">+40 774-885-011</a>
<a href="https://wa.me/40722523764" target="_blank" rel="noreferrer">+40 722-523-764</a>
</strong>
</div>
</div>