From d029e4dc4235da9e4acfda6e93f5ea32d1e05175 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 4 Jul 2026 22:44:43 +0100 Subject: [PATCH] Fix demo fixture entitlement recovery Seed the hidden demo fixture entitlement during stable demo updates so release builds can enable governed mock resources after runtime configuration is restored. Keep the deployment contract and release policy checks aligned with the release-build entitlement gate. --- .github/workflows/update-demo-server.yml | 65 ++++++++++++++++++- .../subsystems/deployment-installability.md | 12 +++- .../license_handlers_demo_fixtures_test.go | 38 +++++++++++ .../installtests/build_release_assets_test.go | 5 ++ .../release_promotion_policy_test.py | 5 ++ 5 files changed, 121 insertions(+), 4 deletions(-) diff --git a/.github/workflows/update-demo-server.yml b/.github/workflows/update-demo-server.yml index f68beb92b..acd10c13b 100644 --- a/.github/workflows/update-demo-server.yml +++ b/.github/workflows/update-demo-server.yml @@ -448,6 +448,39 @@ jobs: SERVICE_NAME="${1:-pulse}" DEMO_LOCAL_BASE_URL="$2" CONFIG_DIR="/etc/${SERVICE_NAME:-pulse}" + SERVICE_USER="$(systemctl show "$SERVICE_NAME" --property=User --value 2>/dev/null || true)" + SERVICE_GROUP="$(systemctl show "$SERVICE_NAME" --property=Group --value 2>/dev/null || true)" + SERVICE_USER="${SERVICE_USER:-pulse}" + SERVICE_GROUP="${SERVICE_GROUP:-$SERVICE_USER}" + + resolve_config_dir() { + local fallback="$CONFIG_DIR" + local unit_env="" + local fragment="" + unit_env="$(systemctl show "$SERVICE_NAME" --property=Environment --value 2>/dev/null || true)" + for entry in $unit_env; do + case "$entry" in + PULSE_DATA_DIR=*) + printf '%s\n' "${entry#PULSE_DATA_DIR=}" + return 0 + ;; + esac + done + + fragment="$(systemctl show "$SERVICE_NAME" --property=FragmentPath --value 2>/dev/null || true)" + if [ -n "$fragment" ] && sudo test -r "$fragment"; then + local unit_data_dir + unit_data_dir="$(sudo sed -n -E 's/^Environment="?PULSE_DATA_DIR=([^"]*)"?$/\1/p' "$fragment" | tail -1)" + if [ -n "$unit_data_dir" ]; then + printf '%s\n' "$unit_data_dir" + return 0 + fi + fi + + printf '%s\n' "$fallback" + } + + CONFIG_DIR="$(resolve_config_dir)" ENV_FILE="$CONFIG_DIR/.env" set_env_value() { @@ -465,6 +498,34 @@ jobs: fi } + ensure_demo_fixture_entitlement() { + local billing_file="$CONFIG_DIR/billing.json" + local tmp_file + tmp_file="$(mktemp)" + sudo mkdir -p "$CONFIG_DIR" + + if sudo test -s "$billing_file" && sudo jq ' + .capabilities = (((.capabilities // []) + ["demo_fixtures"]) | unique) + | .limits = (.limits // {}) + | .meters_enabled = (.meters_enabled // []) + | .plan_version = (if (.plan_version // "") == "" then "community" else .plan_version end) + | .subscription_state = (if (.subscription_state // "") == "" then "active" else .subscription_state end) + | del(.integrity) + ' "$billing_file" > "$tmp_file"; then + : + else + cat > "$tmp_file" <<'JSON' + {"capabilities":["demo_fixtures"],"limits":{},"meters_enabled":[],"plan_version":"community","subscription_state":"active"} + JSON + fi + + sudo tee "$billing_file" < "$tmp_file" >/dev/null + rm -f "$tmp_file" + sudo chown "$SERVICE_USER:$SERVICE_GROUP" "$billing_file" || sudo chown "$SERVICE_USER" "$billing_file" || true + sudo chmod 600 "$billing_file" + echo "Demo fixture entitlement ensured in governed demo billing state." + } + set_env_value DEMO_MODE true set_env_value PULSE_MOCK_MODE true set_env_value PULSE_MOCK_NODES 5 @@ -480,7 +541,9 @@ jobs: set_env_value PULSE_MOCK_RANDOM_METRICS true set_env_value PULSE_MOCK_STOPPED_PERCENT 6 - sudo chown pulse:pulse "$ENV_FILE" || true + ensure_demo_fixture_entitlement + + sudo chown "$SERVICE_USER:$SERVICE_GROUP" "$ENV_FILE" || sudo chown "$SERVICE_USER" "$ENV_FILE" || true sudo chmod 600 "$ENV_FILE" sudo systemctl daemon-reload sudo systemctl restart "$SERVICE_NAME" diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index e6cbf5fb8..c997e1362 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -1066,11 +1066,17 @@ That same stable demo-update boundary must also restore the canonical demo runtime `.env` before verification on every run, including when the service is already on the requested version and the binary update is skipped. The workflow must set `DEMO_MODE=true`, converge the governed `PULSE_MOCK_*` fixture -defaults in `/etc//.env`, restart the selected service, force the -release-build demo-fixture entitlement sync through authenticated -`/api/license/runtime-capabilities`, and then prove both +defaults in the service's resolved runtime `.env`, seed the hidden +`demo_fixtures` capability into the default-org demo `billing.json` entitlement +state, restart the selected service, force the release-build demo-fixture +entitlement sync through authenticated `/api/license/runtime-capabilities`, and +then prove both `/api/system/mock-mode.enabled` and `/api/state.resources[]` converge. A passing `/api/version` or `/api/health` response alone is not demo readiness. +If the workflow mutates an existing demo `billing.json`, it must remove the old +`integrity` field so the running application re-signs the entitlement state +through the canonical billing-state migration path instead of silently treating +the privileged deployment mutation as tampering. That same operator-proof boundary also now owns the canonical hosted staging smoke entrypoint. `scripts/run_hosted_staging_smoke.sh` must stay as the repo-tracked operator command that composes the hosted signup/billing eval pack diff --git a/internal/api/license_handlers_demo_fixtures_test.go b/internal/api/license_handlers_demo_fixtures_test.go index d865d71e3..94f4d4e38 100644 --- a/internal/api/license_handlers_demo_fixtures_test.go +++ b/internal/api/license_handlers_demo_fixtures_test.go @@ -3,6 +3,9 @@ package api import ( + "context" + "os" + "path/filepath" "testing" "github.com/rcourtman/pulse-go-rewrite/internal/config" @@ -139,3 +142,38 @@ func TestSyncReleaseDemoFixtureRuntime_IgnoresNonDefaultOrg(t *testing.T) { t.Fatal("expected non-default org sync to leave process-wide mock mode untouched") } } + +func TestSyncReleaseDemoFixtureRuntime_FileBillingStateSeedEnablesMockFixtures(t *testing.T) { + previousEnabled := mock.IsMockEnabled() + t.Cleanup(func() { + _ = mock.SetEnabled(false) + mock.SetReleaseFixturesAuthorized(false) + _ = mock.SetEnabled(previousEnabled) + }) + _ = mock.SetEnabled(false) + mock.SetReleaseFixturesAuthorized(false) + t.Setenv("PULSE_MOCK_MODE", "true") + + baseDir := t.TempDir() + billingState := []byte(`{"capabilities":["demo_fixtures"],"limits":{},"meters_enabled":[],"plan_version":"community","subscription_state":"active"}`) + if err := os.WriteFile(filepath.Join(baseDir, "billing.json"), billingState, 0o600); err != nil { + t.Fatalf("write demo billing state: %v", err) + } + + handler := NewLicenseHandlers( + config.NewMultiTenantPersistence(baseDir), + false, + &config.Config{DemoMode: true}, + ) + + service, _, err := handler.getTenantComponents(context.Background()) + if err != nil { + t.Fatalf("getTenantComponents: %v", err) + } + if !service.HasFeature(featureDemoFixturesValue) { + t.Fatal("expected seeded demo billing state to authorize demo fixtures") + } + if !mock.IsMockEnabled() { + t.Fatal("expected demo fixture sync to enable mock mode from seeded billing state") + } +} diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index c451570fb..8e0416eef 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -808,8 +808,13 @@ func TestUpdateDemoWorkflowUsesGovernedNetworkPath(t *testing.T) { `Removing demo volatile store: %s`, `Demo host does not have enough free space to back up $CONFIG_DIR before install.`, `Restore demo runtime configuration`, + `resolve_config_dir`, `set_env_value DEMO_MODE true`, `set_env_value PULSE_MOCK_MODE true`, + `ensure_demo_fixture_entitlement`, + `"demo_fixtures"`, + `del(.integrity)`, + `Demo fixture entitlement ensured in governed demo billing state.`, `/api/license/runtime-capabilities`, `Mock mode enabled`, `Demo server mock mode did not enable after entitlement sync`, diff --git a/scripts/release_control/release_promotion_policy_test.py b/scripts/release_control/release_promotion_policy_test.py index 7206000c1..6f3cbd7a7 100644 --- a/scripts/release_control/release_promotion_policy_test.py +++ b/scripts/release_control/release_promotion_policy_test.py @@ -736,8 +736,13 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn("Verify target host identity", demo) self.assertIn("Demo environment points at host $REMOTE_HOSTNAME but expected $DEMO_EXPECTED_HOSTNAME.", demo) self.assertIn("Restore demo runtime configuration", demo) + self.assertIn("resolve_config_dir", demo) self.assertIn("set_env_value DEMO_MODE true", demo) self.assertIn("set_env_value PULSE_MOCK_MODE true", demo) + self.assertIn("ensure_demo_fixture_entitlement", demo) + self.assertIn('"demo_fixtures"', demo) + self.assertIn("del(.integrity)", demo) + self.assertIn("Demo fixture entitlement ensured in governed demo billing state.", demo) self.assertIn("Demo service restarted with governed demo runtime configuration.", demo) self.assertIn("/api/license/runtime-capabilities", demo) self.assertIn("Mock mode enabled", demo)