From 80664352563f9b12121a575ef4bdebd5aad962b6 Mon Sep 17 00:00:00 2001 From: srstack Date: Thu, 15 Oct 2026 19:24:33 +0800 Subject: [PATCH] feat(fs): support the Filesystem-only azure-centralus placement `ti fs --region azure-centralus ...` failed local region validation even though the Azure Central US Filesystem deployment is available. Add the azure provider and its built-in Filesystem endpoint, and keep the Azure placement out of TiDB Cloud Starter placement validation. `region.supported` is shared by DB and Filesystem resolution, so adding azure there would also let `ti db` commands accept `azure-centralus` and send `regions/azure-centralus` to the Starter API, which the product documentation does not list as a Starter placement. Instead, mark the placement as Filesystem-only: `region.Validate` (Starter) rejects it with an explicit error, while the new `region.ValidateFilesystem` accepts it for Filesystem endpoint resolution. `ParsePlacementCode` and `CanonicalCode` keep accepting every known placement so `--region`, `ti configure`, and stored credentials still work. - internal/config/region: add `ProviderAzure`, the azure-centralus placement, and the `filesystemOnly` marker plus `ValidateFilesystem`. - internal/api/endpoints: resolve Filesystem endpoints through `ValidateFilesystem` and add the built-in azure-centralus mapping (`https://azure-centralus.drive9.ai`). - telemetry: accept the azure provider and azure-centralus region code in the CLI normalizer and the backend validator. - README, docs/priciples.md, AGENTS.md, telemetry backend design, and both installers document the new Filesystem region and the Filesystem-only rule. - Regression coverage: placement validation, built-in endpoint routing, Starter rejection at the CLI and binary boundary, stored credentials under an AWS-default profile, token-only companion routing, and telemetry acceptance. `gcp-us-east1` keeps its current behavior in this change; aligning it with the same Filesystem-only model is a separate decision. --- AGENTS.md | 14 +++++- README.md | 6 +-- docs/priciples.md | 2 +- docs/telemetry-backend-design.md | 2 +- e2e/cli_test.go | 53 ++++++++++++++++++++ e2e/installer_test.go | 4 +- internal/api/endpoints/endpoints.go | 3 +- internal/api/endpoints/endpoints_test.go | 24 ++++++++++ internal/cli/root_test.go | 29 +++++++++++ internal/config/region/region.go | 48 ++++++++++++++++++- internal/config/region/region_test.go | 61 ++++++++++++++++++++++-- internal/fs/fscred/credential_test.go | 22 +++++++++ internal/telemetry/telemetry.go | 2 +- internal/telemetry/telemetry_test.go | 27 +++++++++++ internal/telemetrybackend/event.go | 3 +- internal/telemetrybackend/event_test.go | 48 +++++++++++-------- scripts/install.ps1 | 2 +- scripts/install.sh | 2 +- 18 files changed, 314 insertions(+), 38 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2a2ec4e..f1de51f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1001,11 +1001,21 @@ Supported MVP placement values: | `aws-ap-southeast-1` | AWS | Singapore | | `alicloud-ap-southeast-1` | Alibaba Cloud | Singapore | | `gcp-us-east1` | Google Cloud | South Carolina | +| `azure-centralus` | Microsoft Azure | Central US | The prefix before the first `-` is the cloud provider selector. `aws` maps to internal provider `aws`; `alicloud` maps to internal provider -`alibaba_cloud`; `gcp` maps to internal provider `gcp`. Keep this mapping -centralized in `internal/config/region`. +`alibaba_cloud`; `gcp` maps to internal provider `gcp`; `azure` maps to +internal provider `azure`. Keep this mapping centralized in +`internal/config/region`. + +Filesystem-only placements are marked in +`internal/config/region.filesystemOnly`. `azure-centralus` is the current +Filesystem-only placement: `region.ValidateFilesystem` accepts it for +`ti fs`, while `region.Validate` keeps it out of TiDB Cloud Starter placement +validation so `ti db` commands fail locally with an explicit Filesystem-only +error. The built-in Filesystem endpoint mapping remains the authority on which +placements have a Filesystem endpoint. Do not store secrets in logs, telemetry, generated docs examples, or test fixtures. diff --git a/README.md b/README.md index 932632d..56598eb 100644 --- a/README.md +++ b/README.md @@ -104,9 +104,9 @@ Automation should move to `TI_*` and `TIDB_CLOUD_*` environment variables. The v ### Configure - Authentication: a TiDB Cloud Public Key and a Private Key from the [TiDB Cloud API Keys](https://tidbcloud.com/org-settings/api-keys) console. When either credential is missing, `ti` links to this page in its authentication error before suggesting `ti configure` or the corresponding environment variables. -- Default region: one of aws-us-east-1, aws-us-west-2, aws-eu-central-1, aws-ap-northeast-1, aws-ap-southeast-1, alicloud-ap-southeast-1, or gcp-us-east1. - - Regions supporting TiDB Cloud Filesystem: aws-us-east-1, aws-ap-southeast-1, aws-us-west-2, alicloud-ap-southeast-1, or gcp-us-east1. These endpoints are built into `ti`; endpoint resolution does not download a Drive9 region manifest. - - Regions supporting TiDB Cloud Starter: aws-us-east-1, aws-us-west-2, aws-eu-central-1, aws-ap-northeast-1, aws-ap-southeast-1, or alicloud-ap-southeast-1. +- Default region: one of aws-us-east-1, aws-us-west-2, aws-eu-central-1, aws-ap-northeast-1, aws-ap-southeast-1, alicloud-ap-southeast-1, gcp-us-east1, or azure-centralus. + - Regions supporting TiDB Cloud Filesystem: aws-us-east-1, aws-ap-southeast-1, aws-us-west-2, alicloud-ap-southeast-1, gcp-us-east1, or azure-centralus. These endpoints are built into `ti`; endpoint resolution does not download a Drive9 region manifest. + - Regions supporting TiDB Cloud Starter: aws-us-east-1, aws-us-west-2, aws-eu-central-1, aws-ap-northeast-1, aws-ap-southeast-1, or alicloud-ap-southeast-1. `azure-centralus` is a Filesystem-only placement, so `ti db` commands reject it during local placement validation. Set up a default profile with one command: diff --git a/docs/priciples.md b/docs/priciples.md index 838d122..f874dfe 100644 --- a/docs/priciples.md +++ b/docs/priciples.md @@ -77,7 +77,7 @@ Supported TiDB Cloud Starter placement values are: | `aws-ap-southeast-1` | AWS | Singapore | | `alicloud-ap-southeast-1` | Alibaba Cloud | Singapore | -`aws` maps to the internal provider `aws`; `alicloud` maps to `alibaba_cloud`; `gcp` maps to `gcp`. TiDB Cloud Filesystem supports `aws-us-east-1`, `aws-ap-southeast-1`, `aws-us-west-2`, `alicloud-ap-southeast-1`, and `gcp-us-east1` through endpoint mappings built into `ti`. +`aws` maps to the internal provider `aws`; `alicloud` maps to `alibaba_cloud`; `gcp` maps to `gcp`; `azure` maps to `azure`. TiDB Cloud Filesystem supports `aws-us-east-1`, `aws-ap-southeast-1`, `aws-us-west-2`, `alicloud-ap-southeast-1`, `gcp-us-east1`, and `azure-centralus` through endpoint mappings built into `ti`. `azure-centralus` is a Filesystem-only placement: TiDB Cloud Starter commands reject it during local placement validation instead of sending an unsupported region to the Starter API. ## TiDB Cloud Authentication diff --git a/docs/telemetry-backend-design.md b/docs/telemetry-backend-design.md index 822d42f..26e0128 100644 --- a/docs/telemetry-backend-design.md +++ b/docs/telemetry-backend-design.md @@ -192,7 +192,7 @@ Allowed event fields: | `exit_code` | integer | yes | 0 to 255. | | `error_code` | string | no | Stable code only, max 64 bytes; empty string allowed. | | `duration_ms` | integer | yes | 0 to 86,400,000. | -| `cloud_provider` | string | no | `aws`, `alibaba_cloud`, `gcp`, `unknown`, or empty. | +| `cloud_provider` | string | no | `aws`, `alibaba_cloud`, `gcp`, `azure`, `unknown`, or empty. | | `region_code` | string | no | Known ti region code, `unknown`, or empty. | | `cli_version` | string | yes | Version-safe characters only, max 64 bytes. | | `os` | string | yes | Known Go `runtime.GOOS` value. | diff --git a/e2e/cli_test.go b/e2e/cli_test.go index 8f06bff..fd0a79c 100644 --- a/e2e/cli_test.go +++ b/e2e/cli_test.go @@ -1077,6 +1077,59 @@ func TestFSRemoteInventoryAndIDCredentialSelectionAcrossCommandFamilies(t *testi } } +func TestFilesystemOnlyRegionRoutesFileSystemAndRejectsStarter(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("fake companion build path is covered by unit tests on Windows") + } + bin := tiBinary(t) + home := t.TempDir() + companion := filepath.Join(t.TempDir(), "ti-drive9") + build := exec.Command("go", "build", "-o", companion, "./testdata/fake-drive9.go") + build.Dir = "." + if output, err := build.CombinedOutput(); err != nil { + t.Fatalf("build fake Drive9 companion: %v\n%s", err, output) + } + recordPath := filepath.Join(t.TempDir(), "calls.jsonl") + const fileSystemID = "tenant-azure-sandbox" + token := drive9TestToken(fileSystemID) + env := []string{ + "HOME=" + home, + "TI_PROFILE=default", + "TI_REGION_CODE=aws-us-east-1", + "TI_DRIVE9_BIN=" + companion, + "TI_FS_TOKEN=" + token, + "TIDB_CLOUD_PUBLIC_KEY=", + "TIDB_CLOUD_PRIVATE_KEY=", + "TI_ALLOW_TEST_ENDPOINTS=", + "TI_TEST_FS_MANIFEST_URL=http://127.0.0.1:1/must-not-be-used", + "FAKE_DRIVE9_RECORD=" + recordPath, + "DRIVE9_SERVER=https://wrong.example.test", + "DRIVE9_REGION_CODE=aws-us-west-2", + "DRIVE9_API_KEY=wrong-credential", + } + result := runTIWithInput(t, bin, "", env, "--region", "azure-centralus", "fs", "list-files", "--path", "/") + result.wantExitCode(0) + assertFakeDrive9Call(t, readFakeDrive9Calls(t, recordPath), []string{"fs", "ls"}, token, home, "default", fileSystemID, "https://azure-centralus.drive9.ai", "azure-centralus") + for _, name := range []string{"config", "credentials", "fs_credentials"} { + if _, err := os.Stat(filepath.Join(home, ".ti", name)); !os.IsNotExist(err) { + t.Fatalf("token-only access persisted %s: %v", name, err) + } + } + + starterEnv := []string{ + "HOME=" + t.TempDir(), + "TI_REGION_CODE=", + "TIDB_CLOUD_PUBLIC_KEY=test-public", + "TIDB_CLOUD_PRIVATE_KEY=test-private", + } + starter := runTIWithInput(t, bin, "", starterEnv, + "--region", "azure-centralus", + "db", "create-db-cluster", "--db-cluster-type", "starter", "--db-cluster-name", "ti-e2e-azure", "--dry-run", + ) + starter.wantExitCode(2) + starter.wantStderrContains("Filesystem only") +} + func TestFSConfigurationFreeAccess(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("fake companion build path is covered by unit tests on Windows") diff --git a/e2e/installer_test.go b/e2e/installer_test.go index 6333f79..0507bc0 100644 --- a/e2e/installer_test.go +++ b/e2e/installer_test.go @@ -208,7 +208,7 @@ fi t.Fatalf("installer did not validate companion command surface %q; calls:\n%s", want, companionCallData) } } - for _, regionCode := range []string{"aws-us-east-1", "aws-ap-southeast-1", "aws-us-west-2", "alicloud-ap-southeast-1", "gcp-us-east1"} { + for _, regionCode := range []string{"aws-us-east-1", "aws-ap-southeast-1", "aws-us-west-2", "alicloud-ap-southeast-1", "gcp-us-east1", "azure-centralus"} { if !strings.Contains(string(output), regionCode) { t.Fatalf("installer did not list ti fs region %q:\n%s", regionCode, output) } @@ -294,7 +294,7 @@ func TestInstallersUseProductOwnedFSRegionsAndHideCompanionPath(t *testing.T) { if strings.Contains(content, "ti fs companion installed to") { t.Fatalf("%s still reports the companion installation path", path) } - for _, regionCode := range []string{"aws-us-east-1", "aws-ap-southeast-1", "aws-us-west-2", "alicloud-ap-southeast-1", "gcp-us-east1"} { + for _, regionCode := range []string{"aws-us-east-1", "aws-ap-southeast-1", "aws-us-west-2", "alicloud-ap-southeast-1", "gcp-us-east1", "azure-centralus"} { if !strings.Contains(content, regionCode) { t.Fatalf("%s does not list ti fs region %q", path, regionCode) } diff --git a/internal/api/endpoints/endpoints.go b/internal/api/endpoints/endpoints.go index b184727..0464f5f 100644 --- a/internal/api/endpoints/endpoints.go +++ b/internal/api/endpoints/endpoints.go @@ -133,7 +133,7 @@ func (r Resolver) ResolveIAM() (Endpoint, error) { } func (r Resolver) ResolveFS(provider, regionCode string) (Endpoint, error) { - if err := region.Validate(provider, regionCode); err != nil { + if err := region.ValidateFilesystem(provider, regionCode); err != nil { return Endpoint{}, apperr.Wrap("config.invalid_region", "config", 2, err.Error(), err) } @@ -246,6 +246,7 @@ func builtInFSRegionManifest() *FSRegionManifest { {RegionCode: "aws-us-west-2", Mode: DefaultFSMode, ServerURL: "https://aws-us-west-2.drive9.ai", CloudProvider: "aws", TiDBRegion: "us-west-2"}, {RegionCode: "alicloud-ap-southeast-1", Mode: DefaultFSMode, ServerURL: "https://alicloud-ap-southeast-1.drive9.ai", CloudProvider: "alicloud", TiDBRegion: "ap-southeast-1"}, {RegionCode: "gcp-us-east1", Mode: DefaultFSMode, ServerURL: "https://gcp-us-east1.drive9.ai", CloudProvider: "gcp", TiDBRegion: "us-east1"}, + {RegionCode: "azure-centralus", Mode: DefaultFSMode, ServerURL: "https://azure-centralus.drive9.ai", CloudProvider: "azure", TiDBRegion: "centralus"}, }, } } diff --git a/internal/api/endpoints/endpoints_test.go b/internal/api/endpoints/endpoints_test.go index 159d268..9b08794 100644 --- a/internal/api/endpoints/endpoints_test.go +++ b/internal/api/endpoints/endpoints_test.go @@ -160,6 +160,7 @@ func TestResolveFSUsesBuiltInRegionMapping(t *testing.T) { {name: "AWS Oregon", provider: region.ProviderAWS, regionCode: "us-west-2", baseURL: "https://aws-us-west-2.drive9.ai", regionName: "aws-us-west-2"}, {name: "Alibaba Cloud Singapore", provider: region.ProviderAlibabaCloud, regionCode: "ap-southeast-1", baseURL: "https://alicloud-ap-southeast-1.drive9.ai", regionName: "alicloud-ap-southeast-1"}, {name: "GCP South Carolina", provider: region.ProviderGCP, regionCode: "us-east1", baseURL: "https://gcp-us-east1.drive9.ai", regionName: "gcp-us-east1"}, + {name: "Azure Central US", provider: region.ProviderAzure, regionCode: "centralus", baseURL: "https://azure-centralus.drive9.ai", regionName: "azure-centralus"}, } resolver := NewResolver() if resolver.FSManifestURL != "" { @@ -178,6 +179,29 @@ func TestResolveFSUsesBuiltInRegionMapping(t *testing.T) { } } +func TestResolveStarterRejectsFilesystemOnlyPlacement(t *testing.T) { + resolver := NewResolver() + + endpoint, err := resolver.ResolveFS(region.ProviderAzure, "centralus") + if err != nil { + t.Fatalf("ResolveFS failed for the Filesystem-only placement: %v", err) + } + if endpoint.BaseURL != "https://azure-centralus.drive9.ai" || endpoint.RegionName != "azure-centralus" { + t.Fatalf("unexpected fs endpoint: %#v", endpoint) + } + + if _, err := resolver.ResolveStarter(region.ProviderAzure, "centralus"); err == nil { + t.Fatal("expected Starter resolution to reject the Filesystem-only placement") + } else { + if got := apperr.ExitCodeFor(err); got != 2 { + t.Fatalf("expected exit code 2, got %d", got) + } + if message := apperr.MessageFor(err); !strings.Contains(message, "azure-centralus") || !strings.Contains(message, "Filesystem only") { + t.Fatalf("unexpected Starter error message %q", message) + } + } +} + func TestResolveFSUnsupportedManifestRegion(t *testing.T) { resolver := Resolver{ FSManifest: &FSRegionManifest{ diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 1ef5034..b7040bb 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -1147,6 +1147,35 @@ func TestRegionOverrideAllowsEnvironmentCredentialsWithoutEnvRegion(t *testing.T } } +func TestFilesystemOnlyRegionRejectedForStarter(t *testing.T) { + withConfigEnv(t) + + _, _, err := executeForTest("--region", "azure-centralus", "db", "create-db-cluster", "--db-cluster-type", "starter", "--db-cluster-name", "demo-cluster", "--dry-run") + if err == nil { + t.Fatal("expected the Filesystem-only placement to fail for TiDB Cloud Starter") + } + if got := apperr.ExitCodeFor(err); got != 2 { + t.Fatalf("expected exit code 2, got %d", got) + } + if got := apperr.MessageFor(err); !strings.Contains(got, "azure-centralus") || !strings.Contains(got, "Filesystem only") { + t.Fatalf("unexpected error message %q", got) + } +} + +func TestFilesystemOnlyRegionAcceptedForFileSystem(t *testing.T) { + withConfigEnv(t) + + stdout, _, err := executeForTest("--region", "azure-centralus", "fs", "create-file-system", "--dry-run") + if err != nil { + t.Fatalf("expected Azure file system dry-run to succeed: %v", err) + } + for _, want := range []string{"azure centralus", "/v1/admin/tenants"} { + if !strings.Contains(stdout, want) { + t.Fatalf("Azure dry-run missing %q:\n%s", want, stdout) + } + } +} + func TestCreateClusterOmitsProjectSelection(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) diff --git a/internal/config/region/region.go b/internal/config/region/region.go index 61b3beb..483884d 100644 --- a/internal/config/region/region.go +++ b/internal/config/region/region.go @@ -10,11 +10,13 @@ const ( ProviderAWS = "aws" ProviderAlibabaCloud = "alibaba_cloud" ProviderGCP = "gcp" + ProviderAzure = "azure" ProviderPrefixAWS = "aws" ProviderPrefixAlibabaCloud = "alicloud" ProviderPrefixAlibabaCloudLegacy = "ali" ProviderPrefixGCP = "gcp" + ProviderPrefixAzure = "azure" ) type Region struct { @@ -45,6 +47,22 @@ var supported = map[string][]Region{ ProviderGCP: { {Code: "us-east1", Label: "South Carolina"}, }, + ProviderAzure: { + {Code: "centralus", Label: "Central US"}, + }, +} + +// filesystemOnly marks placements that serve TiDB Cloud Filesystem without a +// TiDB Cloud Starter placement. TiDB Cloud Starter resolution rejects them +// through Validate, while Filesystem resolution accepts them through +// ValidateFilesystem; the built-in Filesystem endpoint mapping remains the +// authority on whether a Filesystem endpoint exists for a placement. +var filesystemOnly = map[string]struct{}{ + placementKey(ProviderAzure, "centralus"): {}, +} + +func placementKey(provider, regionCode string) string { + return provider + "\x00" + regionCode } var providerPrefixes = map[string]string{ @@ -52,12 +70,14 @@ var providerPrefixes = map[string]string{ ProviderPrefixAlibabaCloud: ProviderAlibabaCloud, ProviderPrefixAlibabaCloudLegacy: ProviderAlibabaCloud, ProviderPrefixGCP: ProviderGCP, + ProviderPrefixAzure: ProviderAzure, } var providerToPrefix = map[string]string{ ProviderAWS: ProviderPrefixAWS, ProviderAlibabaCloud: ProviderPrefixAlibabaCloud, ProviderGCP: ProviderPrefixGCP, + ProviderAzure: ProviderPrefixAzure, } func Providers() []string { @@ -109,8 +129,11 @@ func DefaultRegion(provider string) (string, error) { return regions[0].Code, nil } +// CanonicalCode returns the canonical placement code for an accepted +// provider/region pair. It accepts every placement ti can address, including +// Filesystem-only placements such as azure-centralus. func CanonicalCode(provider, nativeCode string) (string, error) { - if err := Validate(provider, nativeCode); err != nil { + if err := validatePlacement(provider, nativeCode); err != nil { return "", err } prefix, ok := providerToPrefix[provider] @@ -130,7 +153,7 @@ func ParsePlacementCode(code string) (Placement, error) { if !ok { return Placement{}, fmt.Errorf("unsupported cloud provider prefix %q in region code %q; supported prefixes: %s", prefix, code, joinProviderPrefixes()) } - if err := Validate(provider, nativeCode); err != nil { + if err := validatePlacement(provider, nativeCode); err != nil { return Placement{}, err } regionLabel := "" @@ -151,7 +174,28 @@ func ParsePlacementCode(code string) (Placement, error) { }, nil } +// Validate reports whether provider/regionCode is a supported TiDB Cloud +// Starter placement. A placement that only serves TiDB Cloud Filesystem is +// rejected with an explicit Filesystem-only error instead of being reported as +// unknown. func Validate(provider, regionCode string) error { + if err := validatePlacement(provider, regionCode); err != nil { + return err + } + if _, ok := filesystemOnly[placementKey(provider, regionCode)]; ok { + return fmt.Errorf("placement %q serves TiDB Cloud Filesystem only, not TiDB Cloud Starter", providerToPrefix[provider]+"-"+regionCode) + } + return nil +} + +// ValidateFilesystem reports whether provider/regionCode can be routed to a +// TiDB Cloud Filesystem endpoint. Whether an endpoint exists for the placement +// is decided by the built-in Filesystem region mapping. +func ValidateFilesystem(provider, regionCode string) error { + return validatePlacement(provider, regionCode) +} + +func validatePlacement(provider, regionCode string) error { regions, ok := supported[provider] if !ok { return fmt.Errorf("unsupported cloud provider %q; supported providers: %s", provider, joinProviders()) diff --git a/internal/config/region/region_test.go b/internal/config/region/region_test.go index 50208dc..ec2fe5f 100644 --- a/internal/config/region/region_test.go +++ b/internal/config/region/region_test.go @@ -1,6 +1,9 @@ package region -import "testing" +import ( + "strings" + "testing" +) func TestValidateSupportedProviderRegions(t *testing.T) { tests := []struct { @@ -30,7 +33,8 @@ func TestValidateRejectsUnsupportedProviderRegions(t *testing.T) { provider string region string }{ - {"azure", "eastus"}, + {"unsupported", "centralus"}, + {ProviderAzure, "eastus"}, {ProviderAlibabaCloud, "us-east-1"}, {ProviderAWS, "cn-hangzhou"}, } @@ -44,6 +48,52 @@ func TestValidateRejectsUnsupportedProviderRegions(t *testing.T) { } } +func TestValidateFilesystemAcceptsFilesystemPlacements(t *testing.T) { + tests := []struct { + provider string + region string + }{ + {ProviderAWS, "us-east-1"}, + {ProviderAWS, "ap-southeast-1"}, + {ProviderAlibabaCloud, "ap-southeast-1"}, + {ProviderGCP, "us-east1"}, + {ProviderAzure, "centralus"}, + } + + for _, tt := range tests { + t.Run(tt.provider+"/"+tt.region, func(t *testing.T) { + if err := ValidateFilesystem(tt.provider, tt.region); err != nil { + t.Fatalf("expected filesystem placement to be valid: %v", err) + } + }) + } + + for _, tt := range []struct { + provider string + region string + }{ + {"unsupported", "centralus"}, + {ProviderAzure, "eastus"}, + {ProviderAWS, "cn-hangzhou"}, + } { + t.Run("reject/"+tt.provider+"/"+tt.region, func(t *testing.T) { + if err := ValidateFilesystem(tt.provider, tt.region); err == nil { + t.Fatal("expected filesystem provider/region to be rejected") + } + }) + } +} + +func TestValidateRejectsFilesystemOnlyPlacementForStarter(t *testing.T) { + err := Validate(ProviderAzure, "centralus") + if err == nil { + t.Fatal("expected the Filesystem-only placement to be rejected for Starter") + } + if got := err.Error(); !strings.Contains(got, "azure-centralus") || !strings.Contains(got, "Filesystem only") { + t.Fatalf("unexpected Filesystem-only error %q", got) + } +} + func TestParsePlacementCode(t *testing.T) { tests := []struct { code string @@ -56,6 +106,7 @@ func TestParsePlacementCode(t *testing.T) { {"alicloud-ap-southeast-1", "alicloud-ap-southeast-1", ProviderAlibabaCloud, "ap-southeast-1"}, {"ali-ap-southeast-1", "alicloud-ap-southeast-1", ProviderAlibabaCloud, "ap-southeast-1"}, {"gcp-us-east1", "gcp-us-east1", ProviderGCP, "us-east1"}, + {"azure-centralus", "azure-centralus", ProviderAzure, "centralus"}, } for _, tt := range tests { @@ -67,12 +118,16 @@ func TestParsePlacementCode(t *testing.T) { if placement.Code != tt.canonical || placement.Provider != tt.provider || placement.NativeCode != tt.nativeCode { t.Fatalf("unexpected placement: %#v", placement) } + canonical, err := CanonicalCode(placement.Provider, placement.NativeCode) + if err != nil || canonical != tt.canonical { + t.Fatalf("canonical placement = %q, %v; want %q", canonical, err, tt.canonical) + } }) } } func TestParsePlacementCodeRejectsUnsupportedValues(t *testing.T) { - for _, code := range []string{"us-east-1", "ali-us-east-1", "gcp-us-east-1"} { + for _, code := range []string{"us-east-1", "ali-us-east-1", "gcp-us-east-1", "azure-eastus", "azure-central-us"} { t.Run(code, func(t *testing.T) { if _, err := ParsePlacementCode(code); err == nil { t.Fatal("expected placement code to be rejected") diff --git a/internal/fs/fscred/credential_test.go b/internal/fs/fscred/credential_test.go index fa0345a..073228f 100644 --- a/internal/fs/fscred/credential_test.go +++ b/internal/fs/fscred/credential_test.go @@ -53,6 +53,28 @@ func TestCredentialStoreAndResolveByID(t *testing.T) { } } +func TestCredentialPreservesFilesystemOnlyRegionAcrossProfilePlacements(t *testing.T) { + home := t.TempDir() + profile := credentialTestProfile() + const fileSystemID = "tenant-azure-region" + if _, err := StoreCredential(home, profile, fileSystemID, "azure-centralus", wrappedToken(t, fileSystemID), false); err != nil { + t.Fatal(err) + } + + selected, credential, err := ResolveCredential(home, profile, ResolveCredentialOptions{ + FileSystemID: fileSystemID, FileSystemIDExplicit: true, TokenRequired: true, + }) + if err != nil { + t.Fatal(err) + } + if credential.RegionCode != "azure-centralus" || selected.FSPlacementRegionCode != "azure-centralus" || selected.FSCloudProvider != "azure" || selected.FSRegionCode != "centralus" { + t.Fatalf("stored region = %q, selected region = %q, provider = %q", credential.RegionCode, selected.FSPlacementRegionCode, selected.FSCloudProvider) + } + if profile.CloudProvider != "aws" || profile.PlacementRegionCode != "aws-us-east-1" { + t.Fatal("resolving the resource changed the profile placement") + } +} + func TestCredentialOptionalTokenMetadataAndExactDelete(t *testing.T) { home := t.TempDir() profile := credentialTestProfile() diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index 5d438fc..e5b2ad1 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -468,7 +468,7 @@ func normalizedInstallSource(value string) string { func normalizedProvider(value string) string { switch value { - case "aws", "alibaba_cloud", "gcp": + case "aws", "alibaba_cloud", "gcp", "azure": return value case "": return "" diff --git a/internal/telemetry/telemetry_test.go b/internal/telemetry/telemetry_test.go index 8fb6bf9..5628dd3 100644 --- a/internal/telemetry/telemetry_test.go +++ b/internal/telemetry/telemetry_test.go @@ -316,6 +316,33 @@ func TestFinishSendsOnlyAllowlistedEventFields(t *testing.T) { } } +func TestFinishPreservesAzurePlacement(t *testing.T) { + requests := make(chan []byte, 1) + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + body, _ := io.ReadAll(request.Body) + requests <- body + writer.WriteHeader(http.StatusAccepted) + })) + defer server.Close() + + cfg := enabledConfig(t.TempDir()) + cfg.Endpoint = server.URL + session := Start(cfg) + session.Finish(EventInput{CommandPath: "ti fs list-file-systems", CloudProvider: "azure", RegionCode: "azure-centralus"}) + var batch struct { + Events []struct { + CloudProvider string `json:"cloud_provider"` + RegionCode string `json:"region_code"` + } `json:"events"` + } + if err := json.Unmarshal(<-requests, &batch); err != nil { + t.Fatal(err) + } + if len(batch.Events) != 1 || batch.Events[0].CloudProvider != "azure" || batch.Events[0].RegionCode != "azure-centralus" { + t.Fatalf("Azure placement was not preserved: %#v", batch.Events) + } +} + func TestTelemetryEnvironmentMetadataIsBoundedAndNotPersisted(t *testing.T) { requests := make(chan []byte, 1) server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { diff --git a/internal/telemetrybackend/event.go b/internal/telemetrybackend/event.go index 99a9743..d2d6016 100644 --- a/internal/telemetrybackend/event.go +++ b/internal/telemetrybackend/event.go @@ -72,6 +72,7 @@ var allowedRegions = map[string]struct{}{ "ali-ap-southeast-1": {}, "alicloud-ap-southeast-1": {}, "gcp-us-east1": {}, + "azure-centralus": {}, } var allowedOperatingSystems = map[string]struct{}{ @@ -206,7 +207,7 @@ func validateEvent(raw wireEvent, schemaVersion int, receivedAt time.Time) (Even if raw.DurationMS == nil || *raw.DurationMS < 0 || *raw.DurationMS > 86_400_000 { return Event{}, errors.New("invalid duration_ms") } - if !oneOf(raw.CloudProvider, "", "aws", "alibaba_cloud", "gcp", "unknown") { + if !oneOf(raw.CloudProvider, "", "aws", "alibaba_cloud", "gcp", "azure", "unknown") { return Event{}, errors.New("invalid cloud_provider") } if _, ok := allowedRegions[raw.RegionCode]; !ok { diff --git a/internal/telemetrybackend/event_test.go b/internal/telemetrybackend/event_test.go index 58de5ea..fb4997e 100644 --- a/internal/telemetrybackend/event_test.go +++ b/internal/telemetrybackend/event_test.go @@ -22,24 +22,34 @@ func TestDecodeAndValidateBatchAcceptsAllowlistedEvent(t *testing.T) { } } -func TestDecodeAndValidateBatchAcceptsGCPPlacement(t *testing.T) { - var request map[string]any - if err := json.Unmarshal(validRequestBody(), &request); err != nil { - t.Fatal(err) - } - event := request["events"].([]any)[0].(map[string]any) - event["cloud_provider"] = "gcp" - event["region_code"] = "gcp-us-east1" - body, err := json.Marshal(request) - if err != nil { - t.Fatal(err) - } - events, err := decodeAndValidateBatch(body, 20, time.Now()) - if err != nil { - t.Fatalf("gcp placement should be accepted: %v", err) - } - if len(events) != 1 || events[0].CloudProvider != "gcp" || events[0].RegionCode != "gcp-us-east1" { - t.Fatalf("unexpected gcp event: %#v", events) +func TestDecodeAndValidateBatchAcceptsFilesystemPlacements(t *testing.T) { + for _, test := range []struct { + provider string + region string + }{ + {provider: "gcp", region: "gcp-us-east1"}, + {provider: "azure", region: "azure-centralus"}, + } { + t.Run(test.provider, func(t *testing.T) { + var request map[string]any + if err := json.Unmarshal(validRequestBody(), &request); err != nil { + t.Fatal(err) + } + event := request["events"].([]any)[0].(map[string]any) + event["cloud_provider"] = test.provider + event["region_code"] = test.region + body, err := json.Marshal(request) + if err != nil { + t.Fatal(err) + } + events, err := decodeAndValidateBatch(body, 20, time.Now()) + if err != nil { + t.Fatalf("placement should be accepted: %v", err) + } + if len(events) != 1 || events[0].CloudProvider != test.provider || events[0].RegionCode != test.region { + t.Fatalf("unexpected placement: %#v", events) + } + }) } } @@ -201,7 +211,7 @@ func TestDecodeAndValidateBatchRejectsInvalidEnumsAndLimits(t *testing.T) { {"event_name", "custom.event"}, {"command_path", "rm -rf"}, {"command_path", "ti db list-db-clusters select secret"}, - {"cloud_provider", "azure"}, + {"cloud_provider", "unsupported"}, {"region_code", "us-east-1"}, {"install_source", "curl"}, {"profile_source", "production"}, diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 3374fef..34d4fdb 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -116,7 +116,7 @@ function Print-Regions { Write-Output " alicloud-ap-southeast-1" Write-Output "" Write-Output " ti fs regions:" - Write-Output " aws-us-east-1, aws-ap-southeast-1, aws-us-west-2, alicloud-ap-southeast-1, gcp-us-east1" + Write-Output " aws-us-east-1, aws-ap-southeast-1, aws-us-west-2, alicloud-ap-southeast-1, gcp-us-east1, azure-centralus" } function Print-NextSteps { diff --git a/scripts/install.sh b/scripts/install.sh index 40d928b..ce8b10c 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -280,7 +280,7 @@ print_regions() { printf " alicloud-ap-southeast-1\n" printf "\n" printf " ${BOLD}ti fs regions:${RESET}\n" - printf " aws-us-east-1, aws-ap-southeast-1, aws-us-west-2, alicloud-ap-southeast-1, gcp-us-east1\n" + printf " aws-us-east-1, aws-ap-southeast-1, aws-us-west-2, alicloud-ap-southeast-1, gcp-us-east1, azure-centralus\n" } print_next_steps() {