mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
Harden hosted mobile proof setup
This commit is contained in:
parent
56bc13cb52
commit
badda3781c
11 changed files with 891 additions and 42 deletions
|
|
@ -373,6 +373,7 @@ func printCloudAuditReport(report *cloudcp.CloudAuditReport) {
|
|||
func init() {
|
||||
rootCmd.AddCommand(versionCmd)
|
||||
rootCmd.AddCommand(newCloudCmd())
|
||||
rootCmd.AddCommand(newMobileProofCmd())
|
||||
rootCmd.AddCommand(newTenantRuntimeCmd())
|
||||
}
|
||||
|
||||
|
|
|
|||
361
cmd/pulse-control-plane/mobile_proof_cmd.go
Normal file
361
cmd/pulse-control-plane/mobile_proof_cmd.go
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/cloudcp"
|
||||
cpDocker "github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/docker"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/entitlements"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/registry"
|
||||
cpstripe "github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/stripe"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const defaultMobileProofOwnerEmail = "pulse-mobile-ga-proof@pulserelay.pro"
|
||||
|
||||
type mobileProofRuntime struct {
|
||||
cfg *cloudcp.CPConfig
|
||||
registry *registry.TenantRegistry
|
||||
docker *cpDocker.Manager
|
||||
provisioner *cpstripe.Provisioner
|
||||
}
|
||||
|
||||
func newMobileProofCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "mobile-proof",
|
||||
Short: "Operate disposable hosted workspaces for Pulse Mobile release proofs",
|
||||
Long: "Operate disposable hosted workspaces for Pulse Mobile release proofs.\n\n" +
|
||||
"These commands intentionally use the normal hosted workspace provisioner and refuse\n" +
|
||||
"customer-shaped accounts or tenants unless explicitly overridden.",
|
||||
}
|
||||
cmd.AddCommand(newMobileProofCreateAccountCmd())
|
||||
cmd.AddCommand(newMobileProofCreateWorkspaceCmd())
|
||||
cmd.AddCommand(newMobileProofDeleteWorkspaceCmd())
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newMobileProofCreateAccountCmd() *cobra.Command {
|
||||
var accountID string
|
||||
var displayName string
|
||||
var allowNonProofAccount bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "create-account",
|
||||
Short: "Create a disposable MSP account for Pulse Mobile proof",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
rt, err := newMobileProofRuntime(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rt.close()
|
||||
|
||||
accountID = strings.TrimSpace(accountID)
|
||||
if accountID == "" {
|
||||
generated, err := registry.GenerateAccountID()
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate proof account id: %w", err)
|
||||
}
|
||||
accountID = generated
|
||||
}
|
||||
displayName = strings.TrimSpace(displayName)
|
||||
if displayName == "" {
|
||||
displayName = fmt.Sprintf("Pulse Mobile GA Proof Account %s", time.Now().UTC().Format("20060102T150405Z"))
|
||||
}
|
||||
|
||||
account := ®istry.Account{
|
||||
ID: accountID,
|
||||
Kind: registry.AccountKindMSP,
|
||||
DisplayName: displayName,
|
||||
}
|
||||
if !allowNonProofAccount && !looksLikeMobileProofAccount(account) {
|
||||
return fmt.Errorf("refusing to create non-proof account %s; display name or account id must contain proof/canary/mobile/rehearsal/seed", accountID)
|
||||
}
|
||||
if err := rt.registry.CreateAccount(account); err != nil {
|
||||
return fmt.Errorf("create mobile proof account: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("account_id=%s\n", account.ID)
|
||||
fmt.Printf("kind=%s\n", account.Kind)
|
||||
fmt.Printf("display_name=%s\n", account.DisplayName)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&accountID, "account-id", "", "Optional account ID; generated when omitted")
|
||||
cmd.Flags().StringVar(&displayName, "display-name", "", "Proof account display name; defaults to a Pulse Mobile GA Proof Account timestamp")
|
||||
cmd.Flags().BoolVar(&allowNonProofAccount, "allow-non-proof-account", false, "Allow creation of an account that does not look like a proof account")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newMobileProofCreateWorkspaceCmd() *cobra.Command {
|
||||
var accountID string
|
||||
var displayName string
|
||||
var ownerEmail string
|
||||
var allowNonProofAccount bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "create-workspace",
|
||||
Short: "Create a disposable hosted workspace for Pulse Mobile proof",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
rt, err := newMobileProofRuntime(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rt.close()
|
||||
|
||||
accountID = strings.TrimSpace(accountID)
|
||||
if accountID == "" {
|
||||
return fmt.Errorf("--account-id is required")
|
||||
}
|
||||
if strings.TrimSpace(displayName) == "" {
|
||||
displayName = fmt.Sprintf("Pulse Mobile GA Proof %s", time.Now().UTC().Format("20060102T150405Z"))
|
||||
}
|
||||
ownerEmail = strings.ToLower(strings.TrimSpace(ownerEmail))
|
||||
if ownerEmail == "" {
|
||||
ownerEmail = defaultMobileProofOwnerEmail
|
||||
}
|
||||
|
||||
account, err := rt.registry.GetAccount(accountID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load proof account %s: %w", accountID, err)
|
||||
}
|
||||
if account == nil {
|
||||
return fmt.Errorf("account %s not found", accountID)
|
||||
}
|
||||
if !allowNonProofAccount && !looksLikeMobileProofAccount(account) {
|
||||
return fmt.Errorf("refusing to create mobile proof workspace under non-proof account %s; pass --allow-non-proof-account only after confirming this is not a customer account", accountID)
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(displayName), "proof") && !strings.Contains(strings.ToLower(displayName), "canary") {
|
||||
return fmt.Errorf("--display-name must contain proof or canary")
|
||||
}
|
||||
|
||||
tenant, err := rt.provisioner.ProvisionWorkspaceForOwner(cmd.Context(), accountID, displayName, ownerEmail)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create mobile proof workspace: %w", err)
|
||||
}
|
||||
|
||||
printMobileProofTenant(rt.cfg, tenant)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&accountID, "account-id", "", "Existing proof account ID that owns the disposable workspace")
|
||||
cmd.Flags().StringVar(&displayName, "display-name", "", "Proof workspace display name; defaults to a Pulse Mobile GA Proof timestamp")
|
||||
cmd.Flags().StringVar(&ownerEmail, "owner-email", defaultMobileProofOwnerEmail, "Owner email to seed into the hosted workspace metadata")
|
||||
cmd.Flags().BoolVar(&allowNonProofAccount, "allow-non-proof-account", false, "Allow creation under an account that does not look like a proof account")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newMobileProofDeleteWorkspaceCmd() *cobra.Command {
|
||||
var tenantID string
|
||||
var allowNonProofTenant bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "delete-workspace",
|
||||
Short: "Soft-delete a disposable hosted workspace created for Pulse Mobile proof",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
rt, err := newMobileProofRuntime(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rt.close()
|
||||
|
||||
tenantID = strings.TrimSpace(tenantID)
|
||||
if tenantID == "" {
|
||||
return fmt.Errorf("--tenant-id is required")
|
||||
}
|
||||
|
||||
tenant, err := rt.registry.Get(tenantID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load tenant %s: %w", tenantID, err)
|
||||
}
|
||||
if tenant == nil {
|
||||
return fmt.Errorf("tenant %s not found", tenantID)
|
||||
}
|
||||
account, err := rt.registry.GetAccount(tenant.AccountID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load tenant account %s: %w", tenant.AccountID, err)
|
||||
}
|
||||
if !allowNonProofTenant && !looksLikeMobileProofTenant(tenant, account) {
|
||||
return fmt.Errorf("refusing to delete non-proof tenant %s; pass --allow-non-proof-tenant only after confirming this is not a customer workspace", tenantID)
|
||||
}
|
||||
|
||||
previousState := tenant.State
|
||||
tenant.State = registry.TenantStateDeleting
|
||||
if err := rt.registry.Update(tenant); err != nil {
|
||||
return fmt.Errorf("mark tenant deleting: %w", err)
|
||||
}
|
||||
if err := rt.provisioner.DeprovisionWorkspaceContainer(cmd.Context(), tenant); err != nil {
|
||||
return fmt.Errorf("deprovision tenant container: %w", err)
|
||||
}
|
||||
tenant.ContainerID = ""
|
||||
tenant.State = registry.TenantStateDeleted
|
||||
if err := rt.registry.Update(tenant); err != nil {
|
||||
return fmt.Errorf("mark tenant deleted: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("tenant_id=%s\n", tenant.ID)
|
||||
fmt.Printf("account_id=%s\n", tenant.AccountID)
|
||||
fmt.Printf("previous_state=%s\n", previousState)
|
||||
fmt.Printf("state=%s\n", tenant.State)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&tenantID, "tenant-id", "", "Disposable proof tenant ID to delete")
|
||||
cmd.Flags().BoolVar(&allowNonProofTenant, "allow-non-proof-tenant", false, "Allow deletion of a tenant that does not look like a proof tenant")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newMobileProofRuntime(ctx context.Context) (*mobileProofRuntime, error) {
|
||||
cfg, err := cloudcp.LoadConfig()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load control plane config: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(cfg.TenantsDir(), 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create tenants dir: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(cfg.ControlPlaneDir(), 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create control-plane dir: %w", err)
|
||||
}
|
||||
|
||||
reg, err := registry.NewTenantRegistry(cfg.ControlPlaneDir())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open tenant registry: %w", err)
|
||||
}
|
||||
|
||||
dockerMgr, err := cpDocker.NewManager(cpDocker.ManagerConfig{
|
||||
Image: cfg.PulseImage,
|
||||
Network: cfg.DockerNetwork,
|
||||
BaseDomain: mobileProofBaseDomainFromURL(cfg.BaseURL),
|
||||
TrialActivationPublicKey: cfg.TrialActivationPublicKey,
|
||||
TrustedProxyCIDRs: cfg.TrustedProxyCIDRs,
|
||||
MemoryLimit: cfg.TenantMemoryLimit,
|
||||
CPUShares: cfg.TenantCPUShares,
|
||||
TenantLogMaxSize: cfg.TenantLogMaxSize,
|
||||
TenantLogMaxFile: cfg.TenantLogMaxFile,
|
||||
})
|
||||
if err != nil {
|
||||
reg.Close()
|
||||
return nil, fmt.Errorf("create docker manager: %w", err)
|
||||
}
|
||||
|
||||
hostedEntitlements := entitlements.NewService(reg, cfg.BaseURL, cfg.TrialActivationPrivateKey)
|
||||
provisioner := cpstripe.NewProvisioner(
|
||||
reg,
|
||||
cfg.TenantsDir(),
|
||||
dockerMgr,
|
||||
nil,
|
||||
cfg.BaseURL,
|
||||
nil,
|
||||
cfg.EmailFrom,
|
||||
cfg.AllowDockerlessProvisioning,
|
||||
cpstripe.WithHostedEntitlementService(hostedEntitlements),
|
||||
cpstripe.WithTrialActivationPrivateKey(cfg.TrialActivationPrivateKey),
|
||||
cpstripe.WithAdmissionCheck(func(checkCtx context.Context) error {
|
||||
return cloudcp.EnforceStorageAdmission(checkCtx, cfg, dockerMgr)
|
||||
}),
|
||||
)
|
||||
|
||||
return &mobileProofRuntime{
|
||||
cfg: cfg,
|
||||
registry: reg,
|
||||
docker: dockerMgr,
|
||||
provisioner: provisioner,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (rt *mobileProofRuntime) close() {
|
||||
if rt == nil {
|
||||
return
|
||||
}
|
||||
if rt.docker != nil {
|
||||
rt.docker.Close()
|
||||
}
|
||||
if rt.registry != nil {
|
||||
rt.registry.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func looksLikeMobileProofAccount(account *registry.Account) bool {
|
||||
if account == nil {
|
||||
return false
|
||||
}
|
||||
return containsMobileProofMarker(account.ID, account.DisplayName) ||
|
||||
strings.HasPrefix(strings.ToLower(strings.TrimSpace(account.ID)), "a_msp_") ||
|
||||
strings.Contains(strings.ToLower(strings.TrimSpace(account.ID)), "ownerseed")
|
||||
}
|
||||
|
||||
func looksLikeMobileProofTenant(tenant *registry.Tenant, account *registry.Account) bool {
|
||||
if tenant == nil {
|
||||
return false
|
||||
}
|
||||
return containsMobileProofMarker(
|
||||
tenant.ID,
|
||||
tenant.AccountID,
|
||||
tenant.Email,
|
||||
tenant.DisplayName,
|
||||
tenant.StripeCustomerID,
|
||||
tenant.StripeSubscriptionID,
|
||||
tenant.StripePriceID,
|
||||
tenant.PlanVersion,
|
||||
) || looksLikeMobileProofAccount(account)
|
||||
}
|
||||
|
||||
func containsMobileProofMarker(values ...string) bool {
|
||||
for _, value := range values {
|
||||
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||
if normalized == "" {
|
||||
continue
|
||||
}
|
||||
for _, marker := range []string{"mobile", "proof", "canary", "rehearsal", "seed"} {
|
||||
if strings.Contains(normalized, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func mobileProofBaseDomainFromURL(baseURL string) string {
|
||||
domain := strings.TrimSpace(baseURL)
|
||||
for _, prefix := range []string{"https://", "http://"} {
|
||||
if strings.HasPrefix(domain, prefix) {
|
||||
domain = strings.TrimPrefix(domain, prefix)
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx := strings.IndexAny(domain, ":/"); idx >= 0 {
|
||||
domain = domain[:idx]
|
||||
}
|
||||
return domain
|
||||
}
|
||||
|
||||
func printMobileProofTenant(cfg *cloudcp.CPConfig, tenant *registry.Tenant) {
|
||||
if tenant == nil {
|
||||
return
|
||||
}
|
||||
baseDomain := ""
|
||||
if cfg != nil {
|
||||
baseDomain = mobileProofBaseDomainFromURL(cfg.BaseURL)
|
||||
}
|
||||
publicURL := ""
|
||||
if baseDomain != "" {
|
||||
publicURL = fmt.Sprintf("https://%s.%s", strings.ToLower(tenant.ID), baseDomain)
|
||||
}
|
||||
|
||||
fmt.Printf("tenant_id=%s\n", tenant.ID)
|
||||
fmt.Printf("account_id=%s\n", tenant.AccountID)
|
||||
fmt.Printf("display_name=%s\n", tenant.DisplayName)
|
||||
fmt.Printf("state=%s\n", tenant.State)
|
||||
fmt.Printf("plan_version=%s\n", tenant.PlanVersion)
|
||||
fmt.Printf("container_id=%s\n", tenant.ContainerID)
|
||||
if publicURL != "" {
|
||||
fmt.Printf("public_url=%s\n", publicURL)
|
||||
fmt.Printf("onboarding_url=%s/api/onboarding/qr\n", publicURL)
|
||||
}
|
||||
}
|
||||
138
cmd/pulse-control-plane/mobile_proof_cmd_test.go
Normal file
138
cmd/pulse-control-plane/mobile_proof_cmd_test.go
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/registry"
|
||||
)
|
||||
|
||||
func TestLooksLikeMobileProofAccount(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
account *registry.Account
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "nil",
|
||||
account: nil,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "explicit proof display",
|
||||
account: ®istry.Account{
|
||||
ID: "a_live",
|
||||
DisplayName: "Pulse Mobile Proof",
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "synthetic msp proof id",
|
||||
account: ®istry.Account{
|
||||
ID: "a_msp_prod_fix_20260313111348",
|
||||
DisplayName: "Pulse",
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "customer-shaped account",
|
||||
account: ®istry.Account{
|
||||
ID: "a_W3PT2W0YFR",
|
||||
DisplayName: "Customer Account",
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := looksLikeMobileProofAccount(tt.account); got != tt.want {
|
||||
t.Fatalf("looksLikeMobileProofAccount() = %t, want %t", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLooksLikeMobileProofTenant(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
tenant *registry.Tenant
|
||||
account *registry.Account
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "nil",
|
||||
tenant: nil,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "proof display",
|
||||
tenant: ®istry.Tenant{
|
||||
ID: "t-123",
|
||||
DisplayName: "Pulse Mobile GA Proof",
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "proof account fallback",
|
||||
tenant: ®istry.Tenant{
|
||||
ID: "t-123",
|
||||
AccountID: "a_msp_prod_fix_20260313111348",
|
||||
},
|
||||
account: ®istry.Account{
|
||||
ID: "a_msp_prod_fix_20260313111348",
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "customer tenant",
|
||||
tenant: ®istry.Tenant{
|
||||
ID: "t-123",
|
||||
AccountID: "a_customer",
|
||||
DisplayName: "Customer Workspace",
|
||||
},
|
||||
account: ®istry.Account{
|
||||
ID: "a_customer",
|
||||
DisplayName: "Customer Account",
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := looksLikeMobileProofTenant(tt.tenant, tt.account); got != tt.want {
|
||||
t.Fatalf("looksLikeMobileProofTenant() = %t, want %t", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMobileProofBaseDomainFromURL(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"https://cloud.pulserelay.pro": "cloud.pulserelay.pro",
|
||||
"http://cloud.pulserelay.pro:8080/x": "cloud.pulserelay.pro",
|
||||
"cloud.pulserelay.pro/path": "cloud.pulserelay.pro",
|
||||
" cloud.pulserelay.pro ": "cloud.pulserelay.pro",
|
||||
}
|
||||
|
||||
for input, want := range tests {
|
||||
t.Run(input, func(t *testing.T) {
|
||||
if got := mobileProofBaseDomainFromURL(input); got != want {
|
||||
t.Fatalf("mobileProofBaseDomainFromURL(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMobileProofCommandExposesAccountAndWorkspaceLifecycle(t *testing.T) {
|
||||
cmd := newMobileProofCmd()
|
||||
found := map[string]bool{}
|
||||
for _, sub := range cmd.Commands() {
|
||||
found[sub.Name()] = true
|
||||
}
|
||||
for _, name := range []string{"create-account", "create-workspace", "delete-workspace"} {
|
||||
if !found[name] {
|
||||
t.Fatalf("mobile-proof subcommand %q is not registered", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -24,9 +24,10 @@ server-side update execution surfaces.
|
|||
2. `internal/api/updates.go`
|
||||
3. `frontend-modern/src/api/updates.ts`
|
||||
4. `cmd/pulse-control-plane/main.go`
|
||||
5. `internal/cloudcp/docker/manager.go`
|
||||
6. `internal/cloudcp/docker/labels.go`
|
||||
7. `internal/cloudcp/tenant_runtime_rollout.go`
|
||||
5. `cmd/pulse-control-plane/mobile_proof_cmd.go`
|
||||
6. `internal/cloudcp/docker/manager.go`
|
||||
7. `internal/cloudcp/docker/labels.go`
|
||||
8. `internal/cloudcp/tenant_runtime_rollout.go`
|
||||
8. `.github/workflows/create-release.yml`
|
||||
9. `.github/workflows/deploy-demo-server.yml`
|
||||
10. `.github/workflows/helm-pages.yml`
|
||||
|
|
@ -81,11 +82,13 @@ server-side update execution surfaces.
|
|||
59. `tests/integration/README.md`
|
||||
60. `tests/integration/scripts/bootstrap-hosted-mobile-onboarding.mjs`
|
||||
61. `tests/integration/scripts/hosted-mobile-token-runtime.mjs`
|
||||
62. `tests/integration/scripts/hosted-tenant-runtime.mjs`
|
||||
63. `tests/integration/scripts/managed-dev-runtime.mjs`
|
||||
64. `tests/integration/scripts/relay-mobile-token-helper.go`
|
||||
65. `tests/integration/tests/helpers.ts`
|
||||
66. `tests/integration/tests/runtime-defaults.ts`
|
||||
62. `tests/integration/scripts/hosted-tenant-approval-store.mjs`
|
||||
63. `tests/integration/scripts/hosted-tenant-runtime.mjs`
|
||||
64. `tests/integration/scripts/hosted-tenant-runtime-restart.mjs`
|
||||
65. `tests/integration/scripts/managed-dev-runtime.mjs`
|
||||
66. `tests/integration/scripts/relay-mobile-token-helper.go`
|
||||
67. `tests/integration/tests/helpers.ts`
|
||||
68. `tests/integration/tests/runtime-defaults.ts`
|
||||
67. `docker-compose.yml`
|
||||
68. `scripts/install-docker.sh`
|
||||
69. `scripts/validate-published-release.sh`
|
||||
|
|
@ -197,7 +200,12 @@ server-side update execution surfaces.
|
|||
recorded Docker container is missing, dry-run must classify the tenant for
|
||||
mutation and the live command must recreate the container, prove health, and
|
||||
rewrite the registry runtime identity through the same control-plane path.
|
||||
10. Add or change the canonical hosted staging smoke operator path through `scripts/run_hosted_staging_smoke.sh`, `tests/integration/scripts/bootstrap-hosted-mobile-onboarding.mjs`, `tests/integration/scripts/hosted-mobile-token-runtime.mjs`, `tests/integration/scripts/hosted-tenant-runtime.mjs`, and `tests/integration/scripts/relay-mobile-token-helper.go`
|
||||
10. Add or change the canonical hosted staging smoke operator path through `scripts/run_hosted_staging_smoke.sh`, `tests/integration/scripts/bootstrap-hosted-mobile-onboarding.mjs`, `tests/integration/scripts/hosted-mobile-token-runtime.mjs`, `tests/integration/scripts/hosted-tenant-approval-store.mjs`, `tests/integration/scripts/hosted-tenant-runtime.mjs`, `tests/integration/scripts/hosted-tenant-runtime-restart.mjs`, and `tests/integration/scripts/relay-mobile-token-helper.go`.
|
||||
Hosted mobile proof helpers must create and delete only disposable
|
||||
proof-shaped workspaces through the normal control-plane provisioner,
|
||||
fetch onboarding payloads without logging bearer tokens or mobile deep-link
|
||||
secrets, and seed hosted approvals through a single explicit tenant runtime
|
||||
restart when a release proof needs transactionally visible approval state.
|
||||
|
||||
## Forbidden Paths
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import (
|
|||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
|
|
@ -81,6 +83,36 @@ func TestLoadRelayConfigForRuntime_DoesNotOverrideExplicitDisabledRelayConfig(t
|
|||
}
|
||||
}
|
||||
|
||||
func TestHostedRelayRuntimeBuildsMobileOnboardingAppRelayURL(t *testing.T) {
|
||||
router, _, _ := newHostedRelayRuntimeTestRouter(t)
|
||||
|
||||
relayCfg, err := router.loadRelayConfigForRuntime(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("loadRelayConfigForRuntime() error = %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "https://t-hostedrelay01.cloud.pulserelay.pro/api/onboarding/qr", nil)
|
||||
payload, diagnostics := router.buildOnboardingPayload(req, relayCfg, "test-mobile-proof-token")
|
||||
if hasOnboardingError(diagnostics) {
|
||||
t.Fatalf("onboarding diagnostics contain errors: %#v", diagnostics)
|
||||
}
|
||||
if payload.Relay.URL != "wss://relay.pulserelay.pro/ws/app" {
|
||||
t.Fatalf("mobile onboarding relay URL = %q", payload.Relay.URL)
|
||||
}
|
||||
|
||||
deepLink, err := url.Parse(payload.DeepLink)
|
||||
if err != nil {
|
||||
t.Fatalf("parse deep link: %v", err)
|
||||
}
|
||||
query := deepLink.Query()
|
||||
if query.Get("relay_url") != payload.Relay.URL {
|
||||
t.Fatalf("deep-link relay_url = %q, want %q", query.Get("relay_url"), payload.Relay.URL)
|
||||
}
|
||||
if query.Get("auth_token") != "test-mobile-proof-token" {
|
||||
t.Fatalf("deep-link auth_token did not preserve the proof token")
|
||||
}
|
||||
}
|
||||
|
||||
func newHostedRelayRuntimeTestRouter(t *testing.T) (*Router, string, string) {
|
||||
t.Helper()
|
||||
|
||||
|
|
|
|||
|
|
@ -369,6 +369,22 @@ func TestTenantRuntimeRollout_RecreatesMissingRuntimeFromTenantData(t *testing.T
|
|||
}
|
||||
}
|
||||
|
||||
func TestTenantRuntimeRollout_MobileProofTenantUsesCanonicalRuntimeContract(t *testing.T) {
|
||||
tenantID := "t-MobileProof01"
|
||||
docker := newFakeTenantRuntimeRolloutDocker()
|
||||
routing := docker.DesiredRuntimeRouting(tenantID)
|
||||
|
||||
if got := tenantRuntimeContainerName(tenantID); got != "pulse-t-MobileProof01" {
|
||||
t.Fatalf("tenant runtime container name = %q, want pulse-t-MobileProof01", got)
|
||||
}
|
||||
if routing.Host != "t-mobileproof01.cloud.pulserelay.pro" {
|
||||
t.Fatalf("proof tenant route host = %q", routing.Host)
|
||||
}
|
||||
if routing.PublicURL != "https://t-mobileproof01.cloud.pulserelay.pro" {
|
||||
t.Fatalf("proof tenant public URL = %q", routing.PublicURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTenantRuntimeRollout_AdmissionFailureStopsBeforeSnapshotOrContainerSwap(t *testing.T) {
|
||||
tenant := ®istry.Tenant{ID: "t-ADMIT01", ContainerID: "old-container"}
|
||||
reg := &fakeTenantRuntimeRolloutRegistry{tenant: tenant}
|
||||
|
|
|
|||
|
|
@ -5,15 +5,17 @@ import fs from 'node:fs';
|
|||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
restartHostedTenantRuntime,
|
||||
shellQuote,
|
||||
} from './hosted-tenant-runtime.mjs';
|
||||
import { createHostedRelayMobileToken } from './hosted-mobile-token-runtime.mjs';
|
||||
|
||||
const DEFAULT_POLL_INTERVAL_MS = 500;
|
||||
const DEFAULT_POLL_TIMEOUT_MS = 15_000;
|
||||
const REPO_ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..', '..', '..');
|
||||
const SCRIPT_PATH = fileURLToPath(import.meta.url);
|
||||
|
||||
function usage(message) {
|
||||
if (message) {
|
||||
|
|
@ -96,16 +98,7 @@ function parseArgs(argv) {
|
|||
return parsed;
|
||||
}
|
||||
|
||||
function runText(command, args, options = {}) {
|
||||
return execFileSync(command, args, {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
stdio: 'pipe',
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
function deriveTenantBaseUrl(controlPlaneUrl, tenantId) {
|
||||
export function deriveTenantBaseUrl(controlPlaneUrl, tenantId) {
|
||||
const parsed = new URL(controlPlaneUrl);
|
||||
parsed.hostname = `${tenantId}.${parsed.hostname}`;
|
||||
parsed.pathname = '';
|
||||
|
|
@ -114,24 +107,105 @@ function deriveTenantBaseUrl(controlPlaneUrl, tenantId) {
|
|||
return parsed.toString().replace(/\/$/, '');
|
||||
}
|
||||
|
||||
function curlJson(args) {
|
||||
return JSON.parse(runText('curl', args));
|
||||
export function redactBearerTokens(value) {
|
||||
return String(value).replace(/Bearer\s+[A-Za-z0-9._~+/-]+/g, 'Bearer [REDACTED]');
|
||||
}
|
||||
|
||||
function fetchOnboardingPayload({ rawToken, tenantBaseUrl }) {
|
||||
return curlJson([
|
||||
'-fsS',
|
||||
'-H',
|
||||
`Authorization: Bearer ${rawToken}`,
|
||||
`${tenantBaseUrl}/api/onboarding/qr`,
|
||||
]);
|
||||
export async function fetchOnboardingPayload({ fetchImpl = globalThis.fetch, rawToken, tenantBaseUrl }) {
|
||||
if (typeof fetchImpl !== 'function') {
|
||||
throw new Error('fetch is not available in this Node runtime');
|
||||
}
|
||||
|
||||
const url = `${tenantBaseUrl}/api/onboarding/qr`;
|
||||
let response;
|
||||
try {
|
||||
response = await fetchImpl(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${rawToken}`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(`failed to fetch hosted onboarding payload from ${tenantBaseUrl}: ${redactBearerTokens(error instanceof Error ? error.message : String(error))}`);
|
||||
}
|
||||
|
||||
const body = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(`hosted onboarding payload request returned HTTP ${response.status} from ${tenantBaseUrl}: ${redactBearerTokens(body.slice(0, 500))}`);
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(body);
|
||||
} catch (error) {
|
||||
throw new Error(`hosted onboarding payload from ${tenantBaseUrl} was not valid JSON: ${redactBearerTokens(error instanceof Error ? error.message : String(error))}`);
|
||||
}
|
||||
}
|
||||
|
||||
const REMOTE_ONBOARDING_FETCH_PY = `
|
||||
import json
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
base_url = sys.argv[1].rstrip("/")
|
||||
token = sys.stdin.read().strip()
|
||||
request = urllib.request.Request(
|
||||
base_url + "/api/onboarding/qr",
|
||||
headers={"Authorization": "Bearer " + token},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=20) as response:
|
||||
sys.stdout.write(response.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = exc.read().decode("utf-8", errors="replace")[:500]
|
||||
sys.stderr.write(json.dumps({"status": exc.code, "body": body}))
|
||||
sys.exit(22)
|
||||
except Exception as exc:
|
||||
sys.stderr.write(str(exc))
|
||||
sys.exit(1)
|
||||
`;
|
||||
|
||||
export function fetchOnboardingPayloadViaCloudHost({
|
||||
cloudHost,
|
||||
rawToken,
|
||||
runner = execFileSync,
|
||||
tenantBaseUrl,
|
||||
}) {
|
||||
const host = String(cloudHost ?? '').trim();
|
||||
if (!host) {
|
||||
throw new Error('cloud host is required for hosted onboarding remote fetch');
|
||||
}
|
||||
const command = `python3 -c ${shellQuote(REMOTE_ONBOARDING_FETCH_PY)} ${shellQuote(tenantBaseUrl)}`;
|
||||
let output;
|
||||
try {
|
||||
output = runner('ssh', [host, command], {
|
||||
encoding: 'utf8',
|
||||
input: rawToken,
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
} catch (error) {
|
||||
const detail = ['stderr', 'stdout']
|
||||
.map((field) => error?.[field])
|
||||
.filter((value) => value !== undefined && value !== null && String(value).trim() !== '')
|
||||
.map((value) => String(value).trim())
|
||||
.join('\n') || (error instanceof Error ? error.message : String(error));
|
||||
throw new Error(`failed to fetch hosted onboarding payload via ${host}: ${redactBearerTokens(detail)}`);
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(output);
|
||||
} catch (error) {
|
||||
throw new Error(`hosted onboarding payload fetched via ${host} was not valid JSON: ${redactBearerTokens(error instanceof Error ? error.message : String(error))}`);
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
||||
}
|
||||
|
||||
function pollOnboardingPayload({
|
||||
async function pollOnboardingPayload({
|
||||
cloudHost,
|
||||
fetchImpl,
|
||||
pollIntervalMs,
|
||||
pollTimeoutMs,
|
||||
rawToken,
|
||||
|
|
@ -142,9 +216,16 @@ function pollOnboardingPayload({
|
|||
|
||||
while (Date.now() <= deadline) {
|
||||
try {
|
||||
return fetchOnboardingPayload({ rawToken, tenantBaseUrl });
|
||||
return await fetchOnboardingPayload({ fetchImpl, rawToken, tenantBaseUrl });
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (String(cloudHost ?? '').trim() !== '') {
|
||||
try {
|
||||
return fetchOnboardingPayloadViaCloudHost({ cloudHost, rawToken, tenantBaseUrl });
|
||||
} catch (remoteError) {
|
||||
lastError = remoteError;
|
||||
}
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
break;
|
||||
}
|
||||
|
|
@ -155,7 +236,7 @@ function pollOnboardingPayload({
|
|||
throw new Error(`timed out waiting for hosted onboarding payload from ${tenantBaseUrl}: ${String(lastError instanceof Error ? lastError.message : lastError)}`);
|
||||
}
|
||||
|
||||
function main() {
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-hosted-mobile-onboarding-'));
|
||||
|
||||
|
|
@ -173,7 +254,8 @@ function main() {
|
|||
restartHostedTenantRuntime(args.cloudHost, args.tenantId);
|
||||
|
||||
const tenantBaseUrl = deriveTenantBaseUrl(args.controlPlaneUrl, args.tenantId);
|
||||
const qrPayload = pollOnboardingPayload({
|
||||
const qrPayload = await pollOnboardingPayload({
|
||||
cloudHost: args.cloudHost,
|
||||
pollIntervalMs: args.pollIntervalMs,
|
||||
pollTimeoutMs: args.pollTimeoutMs,
|
||||
rawToken,
|
||||
|
|
@ -216,4 +298,9 @@ function main() {
|
|||
}
|
||||
}
|
||||
|
||||
main();
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === SCRIPT_PATH) {
|
||||
main().catch((error) => {
|
||||
console.error(redactBearerTokens(error instanceof Error ? error.message : String(error)));
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,76 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
deriveTenantBaseUrl,
|
||||
fetchOnboardingPayload,
|
||||
fetchOnboardingPayloadViaCloudHost,
|
||||
redactBearerTokens,
|
||||
} from './bootstrap-hosted-mobile-onboarding.mjs';
|
||||
|
||||
test('deriveTenantBaseUrl maps the control-plane host to the tenant subdomain', () => {
|
||||
assert.equal(
|
||||
deriveTenantBaseUrl('https://cloud.pulserelay.pro/', 't-CANARY123'),
|
||||
'https://t-canary123.cloud.pulserelay.pro',
|
||||
);
|
||||
});
|
||||
|
||||
test('fetchOnboardingPayload uses bearer auth without leaking it in failures', async () => {
|
||||
const token = 'pulse_secret_token.value/with+chars';
|
||||
const calls = [];
|
||||
const fetchImpl = async (url, options) => {
|
||||
calls.push({ options, url });
|
||||
return {
|
||||
ok: false,
|
||||
status: 401,
|
||||
text: async () => `bad Authorization: Bearer ${token}`,
|
||||
};
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() => fetchOnboardingPayload({
|
||||
fetchImpl,
|
||||
rawToken: token,
|
||||
tenantBaseUrl: 'https://t-canary.cloud.pulserelay.pro',
|
||||
}),
|
||||
(error) => {
|
||||
assert.match(error.message, /Bearer \[REDACTED\]/);
|
||||
assert.doesNotMatch(error.message, new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||
return true;
|
||||
},
|
||||
);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].url, 'https://t-canary.cloud.pulserelay.pro/api/onboarding/qr');
|
||||
assert.equal(calls[0].options.headers.Authorization, `Bearer ${token}`);
|
||||
});
|
||||
|
||||
test('redactBearerTokens redacts bearer values in arbitrary command text', () => {
|
||||
assert.equal(
|
||||
redactBearerTokens('Command failed: curl -H Authorization: Bearer abc.def/ghi'),
|
||||
'Command failed: curl -H Authorization: Bearer [REDACTED]',
|
||||
);
|
||||
});
|
||||
|
||||
test('fetchOnboardingPayloadViaCloudHost passes the bearer token over stdin only', () => {
|
||||
const token = 'pulse_secret_token.value/with+chars';
|
||||
const calls = [];
|
||||
const payload = fetchOnboardingPayloadViaCloudHost({
|
||||
cloudHost: 'root@pulse-cloud',
|
||||
rawToken: token,
|
||||
tenantBaseUrl: 'https://t-canary.cloud.pulserelay.pro',
|
||||
runner: (command, args, options) => {
|
||||
calls.push({ args, command, options });
|
||||
return JSON.stringify({
|
||||
deep_link: 'pulse://pair',
|
||||
instance_id: 'relay_123',
|
||||
relay: { enabled: true, url: 'wss://relay.example/ws' },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(payload.instance_id, 'relay_123');
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].command, 'ssh');
|
||||
assert.equal(calls[0].options.input, token);
|
||||
assert.doesNotMatch(calls[0].args.join(' '), new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||
});
|
||||
|
|
@ -5,6 +5,7 @@ import fs from 'node:fs';
|
|||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
import {
|
||||
assertHostedTenantRuntimeExists,
|
||||
|
|
@ -22,12 +23,12 @@ function usage(message) {
|
|||
}
|
||||
|
||||
console.error(
|
||||
'usage: node ./tests/integration/scripts/hosted-tenant-approval-store.mjs <create|get> --tenant-id <id> [--org-id <id>] [--cloud-host <host>] [approval options]',
|
||||
'usage: node ./tests/integration/scripts/hosted-tenant-approval-store.mjs <create|get> --tenant-id <id> [--org-id <id>] [--cloud-host <host>] [--no-restart] [approval options]',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
export function parseArgs(argv) {
|
||||
if (argv.length === 0) {
|
||||
usage('missing action');
|
||||
}
|
||||
|
|
@ -36,6 +37,7 @@ function parseArgs(argv) {
|
|||
action: argv[0],
|
||||
cloudHost: 'root@pulse-cloud',
|
||||
orgId: '',
|
||||
restartAfterCreate: true,
|
||||
tenantId: '',
|
||||
passthrough: [],
|
||||
};
|
||||
|
|
@ -55,6 +57,10 @@ function parseArgs(argv) {
|
|||
parsed.orgId = argv[index + 1] ?? usage('missing value for --org-id');
|
||||
index += 1;
|
||||
break;
|
||||
case '--no-restart':
|
||||
case '--skip-restart':
|
||||
parsed.restartAfterCreate = false;
|
||||
break;
|
||||
case '--help':
|
||||
case '-h':
|
||||
usage();
|
||||
|
|
@ -131,12 +137,14 @@ function main() {
|
|||
const output = runRemote(args.cloudHost, remoteArgs.join(' '));
|
||||
const payload = JSON.parse(output);
|
||||
|
||||
if (args.action === 'create') {
|
||||
if (args.action === 'create' && args.restartAfterCreate) {
|
||||
// Approval store state is loaded in-memory at runtime startup. Hosted proof
|
||||
// seeding edits the backing file out-of-band, so restart the tenant runtime
|
||||
// before returning to ensure the live process serves the seeded approval.
|
||||
restartHostedTenantRuntime(args.cloudHost, args.tenantId);
|
||||
payload.runtimeRestarted = true;
|
||||
} else if (args.action === 'create') {
|
||||
payload.runtimeRestarted = false;
|
||||
}
|
||||
|
||||
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
||||
|
|
@ -148,9 +156,14 @@ function main() {
|
|||
}
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
const invokedAsScript = process.argv[1]
|
||||
&& import.meta.url === pathToFileURL(process.argv[1]).href;
|
||||
|
||||
if (invokedAsScript) {
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
81
tests/integration/scripts/hosted-tenant-runtime-restart.mjs
Normal file
81
tests/integration/scripts/hosted-tenant-runtime-restart.mjs
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import process from 'node:process';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
import { restartHostedTenantRuntime } from './hosted-tenant-runtime.mjs';
|
||||
|
||||
const DEFAULT_CLOUD_HOST = 'root@pulse-cloud';
|
||||
|
||||
function usage(message) {
|
||||
if (message) {
|
||||
console.error(`error: ${message}`);
|
||||
console.error('');
|
||||
}
|
||||
|
||||
console.error(
|
||||
'usage: node ./tests/integration/scripts/hosted-tenant-runtime-restart.mjs --tenant-id <id> [--cloud-host <user@host>]',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
export function parseArgs(argv) {
|
||||
const parsed = {
|
||||
cloudHost: DEFAULT_CLOUD_HOST,
|
||||
tenantId: '',
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
switch (arg) {
|
||||
case '--cloud-host':
|
||||
parsed.cloudHost = argv[index + 1] ?? usage('missing value for --cloud-host');
|
||||
index += 1;
|
||||
break;
|
||||
case '--tenant-id':
|
||||
parsed.tenantId = argv[index + 1] ?? usage('missing value for --tenant-id');
|
||||
index += 1;
|
||||
break;
|
||||
case '--help':
|
||||
case '-h':
|
||||
usage();
|
||||
break;
|
||||
default:
|
||||
usage(`unsupported flag ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
parsed.cloudHost = String(parsed.cloudHost || '').trim();
|
||||
parsed.tenantId = String(parsed.tenantId || '').trim();
|
||||
|
||||
if (!parsed.tenantId) {
|
||||
usage('--tenant-id is required');
|
||||
}
|
||||
if (!parsed.cloudHost) {
|
||||
usage('--cloud-host is required');
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function main(argv = process.argv.slice(2)) {
|
||||
const args = parseArgs(argv);
|
||||
restartHostedTenantRuntime(args.cloudHost, args.tenantId);
|
||||
process.stdout.write(`${JSON.stringify({
|
||||
cloudHost: args.cloudHost,
|
||||
restarted: true,
|
||||
tenantId: args.tenantId,
|
||||
}, null, 2)}\n`);
|
||||
}
|
||||
|
||||
const invokedAsScript = process.argv[1]
|
||||
&& import.meta.url === pathToFileURL(process.argv[1]).href;
|
||||
|
||||
if (invokedAsScript) {
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
|
@ -7,7 +7,9 @@ import {
|
|||
hostedTenantRuntimeExistsScript,
|
||||
restartHostedTenantRuntime,
|
||||
} from './hosted-tenant-runtime.mjs';
|
||||
import { parseArgs as parseApprovalStoreArgs } from './hosted-tenant-approval-store.mjs';
|
||||
import { parseArgs as parseRuntimeCheckArgs } from './hosted-tenant-runtime-check.mjs';
|
||||
import { parseArgs as parseRuntimeRestartArgs } from './hosted-tenant-runtime-restart.mjs';
|
||||
|
||||
test('hostedTenantContainerName derives the canonical runtime container name', () => {
|
||||
assert.equal(hostedTenantContainerName(' t-P62TP8K28Y '), 'pulse-t-P62TP8K28Y');
|
||||
|
|
@ -77,3 +79,37 @@ test('hosted tenant runtime check CLI defaults to the production cloud host', ()
|
|||
assert.equal(parsed.cloudHost, 'root@pulse-cloud');
|
||||
assert.equal(parsed.tenantId, 't-canary');
|
||||
});
|
||||
|
||||
test('hosted tenant runtime restart CLI defaults to the production cloud host', () => {
|
||||
const parsed = parseRuntimeRestartArgs(['--tenant-id', 't-canary']);
|
||||
|
||||
assert.equal(parsed.cloudHost, 'root@pulse-cloud');
|
||||
assert.equal(parsed.tenantId, 't-canary');
|
||||
});
|
||||
|
||||
test('hosted approval seeding keeps runtime restart enabled by default', () => {
|
||||
const parsed = parseApprovalStoreArgs([
|
||||
'create',
|
||||
'--tenant-id',
|
||||
't-canary',
|
||||
'--approval-id',
|
||||
'approval-123',
|
||||
]);
|
||||
|
||||
assert.equal(parsed.restartAfterCreate, true);
|
||||
assert.deepEqual(parsed.passthrough, ['--approval-id', 'approval-123']);
|
||||
});
|
||||
|
||||
test('hosted approval seeding can defer runtime restart for transactional proof setup', () => {
|
||||
const parsed = parseApprovalStoreArgs([
|
||||
'create',
|
||||
'--tenant-id',
|
||||
't-canary',
|
||||
'--no-restart',
|
||||
'--approval-id',
|
||||
'approval-123',
|
||||
]);
|
||||
|
||||
assert.equal(parsed.restartAfterCreate, false);
|
||||
assert.deepEqual(parsed.passthrough, ['--approval-id', 'approval-123']);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue