ops: add CI/CD pipeline, a/b rolling deploy, Gitea Actions workflow
Deploy to Production / deploy (push) Failing after 10s

- .gitea/workflows/deploy.yml — push-to-main triggers rolling deploy
- scripts/deploy-bluegreen.sh — a-stack then b-stack restart; Maven runs
  in Docker (no JDK needed on runner host); Caddy reload at end
- scripts/deploy-all.ps1 — emergency manual deploy from dev machine
- infra/docker-compose.yml — a/b pairs per service; wget health checks;
  Gitea service; Prometheus/Grafana/DB ports restricted to localhost
- infra/Caddyfile — dual upstreams with health-based routing
- infra/Dockerfile.* — one per service
- infra/prometheus.yml + grafana provisioning

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Carsten Rehfeld
2026-05-14 14:01:12 +02:00
parent 5156089152
commit 82f0ac6007
72 changed files with 4715 additions and 27 deletions
@@ -0,0 +1,35 @@
Feature: Device owner anonymity guarantee
As an IoT device owner
I must be able to use the full transition discovery process
Without APIX storing or inferring any artefact that identifies me or my device
Background:
Given a deprecated service "SmartHub Cloud" with locked set to false
And at least one replacement candidate registered
Scenario: Status polling requires no authentication
When GET /services/{smartHubCloudId} is called with no Authorization header
Then the response is HTTP 200
And locked and sunset_date are present in the response body
Scenario: Replacement discovery requires no authentication
When GET /services/{smartHubCloudId}/replacements is called with no Authorization header
Then the response is HTTP 200
And the full replacement list is returned
Scenario: No session state is created during polling
When GET /services/{smartHubCloudId}/replacements is called twice with no shared headers
Then both responses are identical in content
And neither response contains a Set-Cookie header
And neither response contains a session reference
Scenario: Response contains no client-tracking artefacts
When GET /services/{smartHubCloudId}/replacements is called
Then the response headers contain no Set-Cookie
And the response body contains no field that echoes client request details
And the response body contains no correlation ID tied to the caller
Scenario: Polling endpoint is covered by the public cache
When GET /services/{smartHubCloudId}/replacements is called twice within the cache TTL
Then the second response is served from cache
And the cache key does not incorporate any client-identifying header
@@ -0,0 +1,50 @@
Feature: Service decommissioning and historical record preservation
As a template owner
I want to decommission a service after its sunset date
And as a device owner I must be able to access the historical record indefinitely
Background:
Given a registered service "SmartHub Cloud" with endpoint "https://api.smarthub.example"
And the service has capability "device.telemetry"
And the service has locked set to true
And the service is in stage "DEPRECATED" with a sunset_date set
And the service has locked set to false
And a registered service "OpenHub" with endpoint "https://api.openhub.example"
And "OpenHub" has declared compatibility with "SmartHub Cloud"
And the sunset_date of "SmartHub Cloud" has passed
Scenario: Template owner decommissions the service
When the template owner sets service_stage to "DECOMMISSIONED"
Then the service does not appear in any capability search results
And GET /services/{id} returns HTTP 200 with the complete historical record
And the response body contains service_stage "DECOMMISSIONED"
And a version history entry of type "STAGE_CHANGED" exists for the service
Scenario: Decommissioned service with unreleased lock auto-releases for replacement discovery
Given a deprecated service "NeverReleased" that reached DECOMMISSIONED without setting locked=false
When GET /services/{neverReleasedId}/replacements is called with no authentication header
Then the response is HTTP 200
And replacement candidates are returned regardless of the stored locked value
Scenario: Historical record survives indefinitely
Given a decommissioned service
When GET /services/{id} is called
Then the response is HTTP 200
And the full BSM payload is present in the response
And all version history entries are accessible via GET /services/{id}/history
Scenario: Cannot decommission a service before its sunset date
Given a deprecated service "FutureCloud" with a sunset_date 30 days from now
When the template owner attempts to set service_stage to "DECOMMISSIONED"
Then the response is HTTP 422
And the error message contains "sunset_at has not passed"
Scenario: Full transition timeline is visible in version history
Given "SmartHub Cloud" has completed the full lifecycle
When GET /services/{id}/history is called
Then the history contains an entry of type "REGISTERED"
And the history contains an entry of type "SUNSET_DECLARED"
And the history contains an entry of type "LOCK_RELEASED"
And the history contains an entry of type "REPLACEMENT_DECLARED"
And the history contains an entry of type "STAGE_CHANGED" with new value "DECOMMISSIONED"
And all entries are ordered chronologically ascending
@@ -0,0 +1,46 @@
Feature: Replacement provider declares compatibility
As a replacement service provider
I want to declare that my service covers a deprecated template
So that IoT device owners can discover me as a migration target
Background:
Given a deprecated service "SmartHub Cloud" with locked set to false
And a registered service "OpenHub" in stage "PRODUCTION" with O-level "IDENTITY_VERIFIED"
Scenario: Replacement provider declares compatibility with a deprecated service
When "OpenHub" declares replacesServiceIds containing the ID of "SmartHub Cloud"
Then GET /services/{smartHubCloudId}/replacements includes "OpenHub"
And a version history entry of type "REPLACEMENT_DECLARED" exists for "SmartHub Cloud"
And the service_replacements table contains the declared pair
Scenario: Declaration against a non-deprecated service is rejected
Given a service "ActiveCloud" in stage "PRODUCTION" with locked set to true
When "OpenHub" declares replacesServiceIds containing the ID of "ActiveCloud"
Then the response is HTTP 422
And the error message contains "target service is not deprecated"
Scenario: Declaration against a locked deprecated service is rejected
Given a service "LockedCloud" in stage "DEPRECATED" with locked set to true
When "OpenHub" declares replacesServiceIds containing the ID of "LockedCloud"
Then the response is HTTP 422
And the error message contains "target service lock has not been released"
Scenario: Multiple replacement providers for the same deprecated service
Given a service "CloudBridge" in stage "PRODUCTION" with O-level "LEGAL_ENTITY_VERIFIED"
When "OpenHub" declares replacesServiceIds containing the ID of "SmartHub Cloud"
And "CloudBridge" declares replacesServiceIds containing the ID of "SmartHub Cloud"
Then GET /services/{smartHubCloudId}/replacements returns 2 candidates
And "CloudBridge" appears before "OpenHub" in the results
And the ordering is by O-level descending
Scenario: Replacement provider retracts their compatibility declaration
Given "OpenHub" has declared compatibility with "SmartHub Cloud"
When "OpenHub" removes "SmartHub Cloud" from its replacesServiceIds
Then GET /services/{smartHubCloudId}/replacements no longer includes "OpenHub"
And the service_replacements row for this pair is deleted
Scenario: Duplicate declaration is idempotent
Given "OpenHub" has declared compatibility with "SmartHub Cloud"
When "OpenHub" declares replacesServiceIds containing the ID of "SmartHub Cloud" again
Then the response is HTTP 200
And GET /services/{smartHubCloudId}/replacements still returns exactly 1 candidate
@@ -0,0 +1,45 @@
Feature: Device owner discovers replacement services
As an IoT device owner
I want to discover compatible replacement services by polling APIX
Without revealing my identity or device details
Background:
Given a deprecated service "SmartHub Cloud" with locked set to false and a sunset_date set
And "OpenHub" in stage "PRODUCTION" with O-level "IDENTITY_VERIFIED" has declared compatibility
And "CloudBridge" in stage "PRODUCTION" with O-level "LEGAL_ENTITY_VERIFIED" has declared compatibility
Scenario: Device polls service status without authentication
When GET /services/{smartHubCloudId} is called with no authentication header
Then the response is HTTP 200
And the response body contains service_stage "DEPRECATED"
And the response body contains locked false
And the response body contains a sunset_date
Scenario: Device discovers replacement candidates without authentication
When GET /services/{smartHubCloudId}/replacements is called with no authentication header
Then the response is HTTP 200
And the response contains 2 candidates
And "CloudBridge" appears before "OpenHub" in the results
Scenario: Device filters candidates by minimum O-level
When GET /services/{smartHubCloudId}/replacements?minOLevel=LEGAL_ENTITY_VERIFIED is called with no authentication header
Then the response is HTTP 200
And the response contains 1 candidate
And the candidate is "CloudBridge"
Scenario: Device polls a still-locked deprecated service
Given a deprecated service "LockedCloud" with locked set to true
When GET /services/{lockedCloudId}/replacements is called with no authentication header
Then the response is HTTP 200
And the response body contains an empty candidates list
And the response body contains locked true
Scenario: Default production search excludes deprecated services
When GET /services?capability=device.telemetry is called with no authentication header
Then "SmartHub Cloud" is not in the results
And only services with stage "PRODUCTION" are returned
Scenario: Explicit deprecated filter includes deprecated services
When GET /services?capability=device.telemetry&stage=deprecated is called
Then "SmartHub Cloud" is in the results
And each result has service_stage "DEPRECATED"
@@ -0,0 +1,40 @@
Feature: Sunset declaration and lock release
As a template owner
I want to declare a sunset date and release the device lock
So that IoT device owners can prepare for migration in a predictable window
Background:
Given a registered service "SmartHub Cloud" with endpoint "https://api.smarthub.example"
And the service has capability "device.telemetry"
And the service is in stage "PRODUCTION"
And the service has locked set to true
Scenario: Template owner declares sunset date
When the template owner updates the service with sunset_date 90 days from now and stage "DEPRECATED"
Then the service stage is "DEPRECATED"
And the service has a sunset_date set
And a version history entry of type "SUNSET_DECLARED" exists for the service
And the service does not appear in default production search results for capability "device.telemetry"
And the service appears in search results when stage filter is "deprecated"
Scenario: Sunset declaration preserves the device lock
When the template owner updates the service with sunset_date 90 days from now and stage "DEPRECATED"
Then the service has locked set to true
And GET /services/{id}/replacements returns HTTP 200 with an empty list
Scenario: Template owner releases the device lock after sunset is declared
Given the service is in stage "DEPRECATED" with a sunset_date set
When the template owner sets locked to false
Then the service has locked set to false
And a version history entry of type "LOCK_RELEASED" exists for the service
And the version history entry contains the previous locked value true
Scenario: Lock cannot be released without a prior sunset declaration
When the template owner attempts to set locked to false without a sunset_date
Then the response is HTTP 422
And the error message contains "sunset_at required before lock release"
Scenario: Sunset date cannot be set in the past
When the template owner attempts to set sunset_date to yesterday
Then the response is HTTP 422
And the error message contains "sunset_at must be a future moment"