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)