diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index 00152d550e..fcd8bd0b02 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -4,6 +4,8 @@ ### CLI +* `databricks auth profiles` now distinguishes "validation failed" from "couldn't validate". The JSON output adds a `status` field (`valid`, `invalid`, `unknown`, or `unvalidated`) and an `error` description for non-valid profiles. The legacy `valid` field is still emitted as `true` when validation succeeded and `false` when the profile is provably bad (auth/config error); it is omitted for transient/unknown cases that previously misreported as `valid: false`. Each profile is validated with a 10s timeout so a single dead host no longer stalls the listing. + ### Bundles ### Dependency updates diff --git a/acceptance/auth/host-metadata-cache/output.txt b/acceptance/auth/host-metadata-cache/output.txt index 0b99e9579c..af4872d746 100644 --- a/acceptance/auth/host-metadata-cache/output.txt +++ b/acceptance/auth/host-metadata-cache/output.txt @@ -7,7 +7,7 @@ "host": "[DATABRICKS_URL]", "cloud": "aws", "auth_type": "", - "valid": false + "status": "unvalidated" } ] } @@ -20,7 +20,7 @@ "host": "[DATABRICKS_URL]", "cloud": "aws", "auth_type": "", - "valid": false + "status": "unvalidated" } ] } diff --git a/acceptance/cmd/auth/login/discovery/output.txt b/acceptance/cmd/auth/login/discovery/output.txt index c687b07fd5..7213fe7384 100644 --- a/acceptance/cmd/auth/login/discovery/output.txt +++ b/acceptance/cmd/auth/login/discovery/output.txt @@ -5,7 +5,7 @@ Profile discovery-test was successfully saved >>> [CLI] auth profiles Name Host Valid -discovery-test (Default) [DATABRICKS_URL] YES +discovery-test (Default) [DATABRICKS_URL] valid >>> print_requests.py --get //tokens/introspect { diff --git a/acceptance/cmd/auth/login/nominal/output.txt b/acceptance/cmd/auth/login/nominal/output.txt index 4200636bc2..9ce51ea84a 100644 --- a/acceptance/cmd/auth/login/nominal/output.txt +++ b/acceptance/cmd/auth/login/nominal/output.txt @@ -4,4 +4,4 @@ Profile test was successfully saved >>> [CLI] auth profiles Name Host Valid -test (Default) [DATABRICKS_URL] YES +test (Default) [DATABRICKS_URL] valid diff --git a/acceptance/cmd/auth/logout/stale-account-id-workspace-host/output.txt b/acceptance/cmd/auth/logout/stale-account-id-workspace-host/output.txt index 29c2d40709..d24d213661 100644 --- a/acceptance/cmd/auth/logout/stale-account-id-workspace-host/output.txt +++ b/acceptance/cmd/auth/logout/stale-account-id-workspace-host/output.txt @@ -2,7 +2,7 @@ === Profiles before logout — logfood should be valid >>> [CLI] auth profiles Name Host Valid -logfood (Default) [DATABRICKS_URL] YES +logfood (Default) [DATABRICKS_URL] valid === Token cache keys before logout [ @@ -32,7 +32,7 @@ default_profile = logfood === Profiles after logout — logfood should be invalid >>> [CLI] auth profiles Name Host Valid -logfood (Default) [DATABRICKS_URL] NO +logfood (Default) [DATABRICKS_URL] unknown === Logged out profile should no longer return a token >>> musterr [CLI] auth token --profile logfood diff --git a/acceptance/cmd/auth/profiles/expired-token/out.test.toml b/acceptance/cmd/auth/profiles/expired-token/out.test.toml new file mode 100644 index 0000000000..f784a18325 --- /dev/null +++ b/acceptance/cmd/auth/profiles/expired-token/out.test.toml @@ -0,0 +1,3 @@ +Local = true +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] diff --git a/acceptance/cmd/auth/profiles/expired-token/output.txt b/acceptance/cmd/auth/profiles/expired-token/output.txt new file mode 100644 index 0000000000..0a22f49a2e --- /dev/null +++ b/acceptance/cmd/auth/profiles/expired-token/output.txt @@ -0,0 +1,16 @@ + +=== Expired token: profile is reported invalid with remediation hint +>>> [CLI] auth profiles --output json +{ + "profiles": [ + { + "name": "expired", + "host": "[DATABRICKS_URL]", + "cloud": "aws", + "auth_type": "pat", + "status": "invalid", + "error": "authentication failed (token may have expired — try 'databricks auth login -p expired')", + "valid": false + } + ] +} diff --git a/acceptance/cmd/auth/profiles/expired-token/script b/acceptance/cmd/auth/profiles/expired-token/script new file mode 100644 index 0000000000..1a48a8951b --- /dev/null +++ b/acceptance/cmd/auth/profiles/expired-token/script @@ -0,0 +1,10 @@ +sethome "./home" + +cat > "./home/.databrickscfg" <>> [CLI] auth profiles --output json +{ + "profiles": [ + { + "name": "transient", + "host": "[DATABRICKS_URL]", + "cloud": "aws", + "auth_type": "pat", + "status": "unknown", + "error": "server error: 500" + } + ] +} diff --git a/acceptance/cmd/auth/profiles/server-error/script b/acceptance/cmd/auth/profiles/server-error/script new file mode 100644 index 0000000000..7871aadfb9 --- /dev/null +++ b/acceptance/cmd/auth/profiles/server-error/script @@ -0,0 +1,10 @@ +sethome "./home" + +cat > "./home/.databrickscfg" <>> [CLI] auth profiles --skip-validate Name Host Valid -profile-a (Default) [DATABRICKS_URL] NO -profile-b [DATABRICKS_URL] NO +profile-a (Default) [DATABRICKS_URL] - +profile-b [DATABRICKS_URL] - === Switch to profile-b @@ -28,5 +28,5 @@ default_profile = profile-b >>> [CLI] auth profiles --skip-validate Name Host Valid -profile-a [DATABRICKS_URL] NO -profile-b (Default) [DATABRICKS_URL] NO +profile-a [DATABRICKS_URL] - +profile-b (Default) [DATABRICKS_URL] - diff --git a/cmd/auth/profiles.go b/cmd/auth/profiles.go index 51c397a9ea..93e9b47bb8 100644 --- a/cmd/auth/profiles.go +++ b/cmd/auth/profiles.go @@ -5,6 +5,8 @@ import ( "errors" "fmt" "io/fs" + "net" + "net/url" "sync" "time" @@ -15,32 +17,148 @@ import ( "github.com/databricks/cli/libs/env" "github.com/databricks/cli/libs/log" "github.com/databricks/databricks-sdk-go" + "github.com/databricks/databricks-sdk-go/apierr" "github.com/databricks/databricks-sdk-go/config" "github.com/spf13/cobra" "gopkg.in/ini.v1" ) +// profileStatus is the three-state result of validating a profile, plus a +// fourth state for "validation skipped". Reported as `status` in JSON output. +type profileStatus string + +const ( + profileStatusValid profileStatus = "valid" // API call succeeded + profileStatusInvalid profileStatus = "invalid" // proven bad: auth/config error + profileStatusUnknown profileStatus = "unknown" // could not determine (network, transient) + profileStatusUnvalidated profileStatus = "unvalidated" // --skip-validate +) + +// profileValidationTimeout bounds the per-profile validation API call so a +// single hung profile (dead host, no route) does not stall the whole listing. +const profileValidationTimeout = 10 * time.Second + type profileMetadata struct { - Name string `json:"name"` - Host string `json:"host,omitempty"` - AccountID string `json:"account_id,omitempty"` - WorkspaceID string `json:"workspace_id,omitempty"` - Cloud string `json:"cloud"` - AuthType string `json:"auth_type"` - Valid bool `json:"valid"` - Default bool `json:"default,omitempty"` + Name string `json:"name"` + Host string `json:"host,omitempty"` + AccountID string `json:"account_id,omitempty"` + WorkspaceID string `json:"workspace_id,omitempty"` + Cloud string `json:"cloud"` + AuthType string `json:"auth_type"` + Status profileStatus `json:"status"` + Error string `json:"error,omitempty"` + Default bool `json:"default,omitempty"` + + // Valid is the legacy compat field. Emitted only when Status is conclusively + // valid or invalid; absent (omitempty) for unknown/unvalidated. Old scripts + // that branch on `valid: true` keep working; scripts that branch on + // `valid: false` keep working for proven-bad profiles and now see the field + // missing for the cases we previously misreported as "false". + Valid *bool `json:"valid,omitempty"` + + // statusDisplay is the colored cell rendered for the text "Valid" column. + // Populated by Load; never serialized. + StatusDisplay string `json:"-"` } func (c *profileMetadata) IsEmpty() bool { return c.Host == "" && c.AccountID == "" } +// setStatus records the classification result and keeps the legacy Valid field +// in sync. Valid is left nil for unknown/unvalidated so omitempty drops it. +func (c *profileMetadata) setStatus(ctx context.Context, status profileStatus, msg string) { + c.Status = status + c.Error = msg + switch status { + case profileStatusValid: + t := true + c.Valid = &t + case profileStatusInvalid: + f := false + c.Valid = &f + case profileStatusUnknown, profileStatusUnvalidated: + // Leave Valid nil; omitempty drops the legacy field. + } + c.StatusDisplay = renderStatusCell(ctx, status) +} + +// renderStatusCell formats a profileStatus for the text "Valid" column. We +// keep the values short so the column width stays close to the previous +// YES/NO output. +func renderStatusCell(ctx context.Context, s profileStatus) string { + switch s { + case profileStatusValid: + return cmdio.Green(ctx, "valid") + case profileStatusInvalid: + return cmdio.Red(ctx, "invalid") + case profileStatusUnknown: + return cmdio.Yellow(ctx, "unknown") + case profileStatusUnvalidated: + return "-" + } + return "-" +} + +// classifyValidationError maps an error returned by the validation API call +// to a (status, message) pair. The profile name is interpolated into auth +// remediation hints. Order matters: timeout is checked first because the +// SDK can wrap context.DeadlineExceeded in url.Error, and APIError sentinels +// before the generic *url.Error / *net.OpError fallthrough. +func classifyValidationError(profileName string, err error) (profileStatus, string) { + if err == nil { + return profileStatusValid, "" + } + if errors.Is(err, context.DeadlineExceeded) { + return profileStatusUnknown, "validation timed out" + } + if errors.Is(err, apierr.ErrUnauthenticated) { + return profileStatusInvalid, fmt.Sprintf("authentication failed (token may have expired — try 'databricks auth login -p %s')", profileName) + } + if errors.Is(err, apierr.ErrPermissionDenied) { + return profileStatusInvalid, "credentials lack permission for the validation API call" + } + var apiErr *apierr.APIError + if errors.As(err, &apiErr) && apiErr.StatusCode >= 500 { + return profileStatusUnknown, fmt.Sprintf("server error: %d", apiErr.StatusCode) + } + if reason, ok := networkErrorReason(err); ok { + return profileStatusUnknown, "could not reach host: " + reason + } + return profileStatusUnknown, err.Error() +} + +// networkErrorReason returns the short reason from a *url.Error / *net.OpError +// chain (skipping the URL prefix that url.Error.Error() prepends). The second +// return is false when err is not a network error. +func networkErrorReason(err error) (string, bool) { + var urlErr *url.Error + if errors.As(err, &urlErr) && urlErr.Err != nil { + return urlErr.Err.Error(), true + } + var netErr *net.OpError + if errors.As(err, &netErr) { + return netErr.Error(), true + } + return "", false +} + func (c *profileMetadata) Load(ctx context.Context, configFilePath string, skipValidate bool) { + timeoutSeconds := int(profileValidationTimeout / time.Second) cfg := &config.Config{ Loaders: []config.Loader{config.ConfigFile}, ConfigFile: configFilePath, Profile: c.Name, DatabricksCliPath: env.Get(ctx, "DATABRICKS_CLI_PATH"), + + // Bound the SDK's per-request and total-retry budgets to the same + // per-profile ceiling. EnsureResolved fetches host metadata via the + // SDK's retrier, which defaults to 5 minutes — without this, a single + // dead host stalls the listing well past the validation context's + // timeout (the EnsureResolved call uses context.Background internally, + // so our context.WithTimeout below cannot reach it). + HTTPTimeoutSeconds: timeoutSeconds, + RetryTimeoutSeconds: timeoutSeconds, } _ = cfg.EnsureResolved() if cfg.IsAws() { @@ -54,6 +172,7 @@ func (c *profileMetadata) Load(ctx context.Context, configFilePath string, skipV if skipValidate { c.Host = cfg.CanonicalHostName() c.AuthType = cfg.AuthType + c.setStatus(ctx, profileStatusUnvalidated, "") return } @@ -62,35 +181,37 @@ func (c *profileMetadata) Load(ctx context.Context, configFilePath string, skipV log.Debugf(ctx, "Profile %q: overrode config type from %s to %s (SPOG host)", c.Name, cfg.ConfigType(), configType) } + if configType == config.InvalidConfig { + c.setStatus(ctx, profileStatusInvalid, "profile fields conflict (e.g. workspace and account configured together)") + return + } + + callCtx, cancel := context.WithTimeout(ctx, profileValidationTimeout) + defer cancel() + + var err error switch configType { case config.AccountConfig: - a, err := databricks.NewAccountClient((*databricks.Config)(cfg)) - if err != nil { - return + var a *databricks.AccountClient + a, err = databricks.NewAccountClient((*databricks.Config)(cfg)) + if err == nil { + _, err = a.Workspaces.List(callCtx) } - _, err = a.Workspaces.List(ctx) - c.Host = cfg.Host - c.AuthType = cfg.AuthType - if err != nil { - return - } - c.Valid = true case config.WorkspaceConfig: - w, err := databricks.NewWorkspaceClient((*databricks.Config)(cfg)) - if err != nil { - return + var w *databricks.WorkspaceClient + w, err = databricks.NewWorkspaceClient((*databricks.Config)(cfg)) + if err == nil { + _, err = w.CurrentUser.Me(callCtx) } - _, err = w.CurrentUser.Me(ctx) - c.Host = cfg.Host - c.AuthType = cfg.AuthType - if err != nil { - return - } - c.Valid = true case config.InvalidConfig: - // Invalid configuration, skip validation - return + // Handled above with an early return; listed here for switch exhaustiveness. } + + c.Host = cfg.Host + c.AuthType = cfg.AuthType + + status, msg := classifyValidationError(c.Name, err) + c.setStatus(ctx, status, msg) } func newProfilesCommand() *cobra.Command { @@ -100,7 +221,7 @@ func newProfilesCommand() *cobra.Command { Annotations: map[string]string{ "template": cmdio.Heredoc(` {{header "Name"}} {{header "Host"}} {{header "Valid"}} - {{range .Profiles}}{{.Name | green}}{{if .Default}} (Default){{end}} {{.Host|cyan}} {{bool .Valid}} + {{range .Profiles}}{{.Name | green}}{{if .Default}} (Default){{end}} {{.Host|cyan}} {{.StatusDisplay}} {{end}}`), }, } diff --git a/cmd/auth/profiles_test.go b/cmd/auth/profiles_test.go index 59803e210c..46db0e7d02 100644 --- a/cmd/auth/profiles_test.go +++ b/cmd/auth/profiles_test.go @@ -1,15 +1,19 @@ package auth import ( + "context" "encoding/json" + "errors" "net/http" "net/http/httptest" + "net/url" "os" "path/filepath" "runtime" "testing" "github.com/databricks/cli/libs/databrickscfg" + "github.com/databricks/databricks-sdk-go/apierr" "github.com/databricks/databricks-sdk-go/config" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -46,6 +50,8 @@ func TestProfiles(t *testing.T) { assert.Equal(t, "https://abc.cloud.databricks.com", profile.Host) assert.Equal(t, "aws", profile.Cloud) assert.Equal(t, "pat", profile.AuthType) + assert.Equal(t, profileStatusUnvalidated, profile.Status) + assert.Nil(t, profile.Valid, "Valid should be unset for unvalidated") } func TestProfilesDefaultMarker(t *testing.T) { @@ -139,33 +145,28 @@ func TestProfileLoadSPOGConfigType(t *testing.T) { host string accountID string workspaceID string - wantValid bool }{ { name: "SPOG account profile validated as account", host: spogServer.URL, accountID: "spog-acct", - wantValid: true, }, { name: "SPOG workspace profile validated as workspace", host: spogServer.URL, accountID: "spog-acct", workspaceID: "ws-123", - wantValid: true, }, { name: "SPOG profile with workspace_id=none validated as account", host: spogServer.URL, accountID: "spog-acct", workspaceID: "none", - wantValid: true, }, { name: "classic workspace with account_id from discovery stays workspace", host: wsServer.URL, accountID: "ws-acct", - wantValid: true, }, } @@ -194,7 +195,9 @@ func TestProfileLoadSPOGConfigType(t *testing.T) { } p.Load(t.Context(), configFile, false) - assert.Equal(t, tc.wantValid, p.Valid, "Valid mismatch") + assert.Equal(t, profileStatusValid, p.Status, "status mismatch") + require.NotNil(t, p.Valid) + assert.True(t, *p.Valid) assert.NotEmpty(t, p.Host, "Host should be set") assert.NotEmpty(t, p.AuthType, "AuthType should be set") }) @@ -250,7 +253,204 @@ func TestProfileLoadNoDiscoveryStaysWorkspace(t *testing.T) { } p.Load(t.Context(), configFile, false) - assert.True(t, p.Valid, "should validate as workspace when discovery is unavailable") + assert.Equal(t, profileStatusValid, p.Status, "should validate as workspace when discovery is unavailable") assert.NotEmpty(t, p.Host) assert.Equal(t, "pat", p.AuthType) } + +func TestClassifyValidationError(t *testing.T) { + cases := []struct { + name string + err error + wantStatus profileStatus + wantMsgSub string + }{ + { + name: "nil error -> valid", + err: nil, + wantStatus: profileStatusValid, + }, + { + name: "deadline exceeded -> unknown timeout", + err: context.DeadlineExceeded, + wantStatus: profileStatusUnknown, + wantMsgSub: "validation timed out", + }, + { + name: "url.Error wrapping deadline -> unknown timeout", + err: &url.Error{Op: "Get", URL: "https://x.test/", Err: context.DeadlineExceeded}, + wantStatus: profileStatusUnknown, + wantMsgSub: "validation timed out", + }, + { + name: "401 -> invalid with auth remediation", + err: &apierr.APIError{StatusCode: 401, Message: "unauthorized"}, + wantStatus: profileStatusInvalid, + wantMsgSub: "databricks auth login -p test-profile", + }, + { + name: "403 -> invalid with permission message", + err: &apierr.APIError{StatusCode: 403, Message: "forbidden"}, + wantStatus: profileStatusInvalid, + wantMsgSub: "credentials lack permission", + }, + { + name: "500 -> unknown server error", + err: &apierr.APIError{StatusCode: 500, Message: "internal"}, + wantStatus: profileStatusUnknown, + wantMsgSub: "server error: 500", + }, + { + name: "503 -> unknown server error", + err: &apierr.APIError{StatusCode: 503, Message: "unavailable"}, + wantStatus: profileStatusUnknown, + wantMsgSub: "server error: 503", + }, + { + name: "network error -> unknown could-not-reach", + err: &url.Error{Op: "Get", URL: "https://x.test/", Err: errors.New("dial tcp: lookup x.test: no such host")}, + wantStatus: profileStatusUnknown, + wantMsgSub: "could not reach host", + }, + { + name: "fallthrough -> unknown with raw message", + err: errors.New("strange unknown failure"), + wantStatus: profileStatusUnknown, + wantMsgSub: "strange unknown failure", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + status, msg := classifyValidationError("test-profile", tc.err) + assert.Equal(t, tc.wantStatus, status) + if tc.wantMsgSub == "" { + assert.Empty(t, msg) + } else { + assert.Contains(t, msg, tc.wantMsgSub) + } + }) + } +} + +func TestProfileLoadStatusMatrix(t *testing.T) { + // statusServer returns a configurable HTTP status for the validation + // endpoint. .well-known returns 404 so we land on WorkspaceConfig and + // CurrentUser.Me is the validation API call. + statusServer := func(t *testing.T, code int) *httptest.Server { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/.well-known/databricks-config": + w.WriteHeader(http.StatusNotFound) + case "/api/2.0/preview/scim/v2/Me": + w.WriteHeader(code) + _, _ = w.Write([]byte(`{"error_code":"X","message":"x"}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(server.Close) + return server + } + + t.Run("401 -> invalid", func(t *testing.T) { + s := statusServer(t, http.StatusUnauthorized) + p := loadFromHost(t, s.URL) + assert.Equal(t, profileStatusInvalid, p.Status) + require.NotNil(t, p.Valid) + assert.False(t, *p.Valid) + assert.Contains(t, p.Error, "databricks auth login") + }) + + t.Run("403 -> invalid", func(t *testing.T) { + s := statusServer(t, http.StatusForbidden) + p := loadFromHost(t, s.URL) + assert.Equal(t, profileStatusInvalid, p.Status) + require.NotNil(t, p.Valid) + assert.False(t, *p.Valid) + assert.Contains(t, p.Error, "permission") + }) + + t.Run("500 -> unknown", func(t *testing.T) { + s := statusServer(t, http.StatusInternalServerError) + p := loadFromHost(t, s.URL) + assert.Equal(t, profileStatusUnknown, p.Status) + assert.Nil(t, p.Valid, "Valid is omitted for unknown") + assert.Contains(t, p.Error, "server error") + }) + + t.Run("network down -> unknown", func(t *testing.T) { + // Start and immediately close the server to simulate a dead host. + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + s.Close() + p := loadFromHost(t, s.URL) + assert.Equal(t, profileStatusUnknown, p.Status) + assert.Nil(t, p.Valid) + assert.Contains(t, p.Error, "could not reach host") + }) + + t.Run("InvalidConfig -> invalid", func(t *testing.T) { + // experimental_is_unified_host=true forces HostType=UnifiedHost. + // Without an account_id (or a SPOG-shaped DiscoveryURL), ResolveConfigType + // can't pick a side and falls through to InvalidConfig. + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + t.Cleanup(server.Close) + + dir := t.TempDir() + configFile := filepath.Join(dir, ".databrickscfg") + t.Setenv("HOME", dir) + if runtime.GOOS == "windows" { + t.Setenv("USERPROFILE", dir) + } + content := "[bad]\nhost = " + server.URL + "\nexperimental_is_unified_host = true\ntoken = test-token\n" + require.NoError(t, os.WriteFile(configFile, []byte(content), 0o600)) + + p := &profileMetadata{Name: "bad", Host: server.URL} + p.Load(t.Context(), configFile, false) + assert.Equal(t, profileStatusInvalid, p.Status) + require.NotNil(t, p.Valid) + assert.False(t, *p.Valid) + assert.Contains(t, p.Error, "fields conflict") + }) + + t.Run("skip-validate -> unvalidated", func(t *testing.T) { + s := statusServer(t, http.StatusOK) + p := loadFromHost(t, s.URL, withSkipValidate()) + assert.Equal(t, profileStatusUnvalidated, p.Status) + assert.Nil(t, p.Valid) + assert.Empty(t, p.Error) + }) +} + +type loadOpts struct { + skipValidate bool +} + +type loadOpt func(*loadOpts) + +func withSkipValidate() loadOpt { return func(o *loadOpts) { o.skipValidate = true } } + +// loadFromHost writes a single PAT profile pointing at host into a temp +// .databrickscfg, runs Load, and returns the populated profileMetadata. +func loadFromHost(t *testing.T, host string, opts ...loadOpt) *profileMetadata { + t.Helper() + o := loadOpts{} + for _, opt := range opts { + opt(&o) + } + dir := t.TempDir() + configFile := filepath.Join(dir, ".databrickscfg") + t.Setenv("HOME", dir) + if runtime.GOOS == "windows" { + t.Setenv("USERPROFILE", dir) + } + content := "[test-profile]\nhost = " + host + "\ntoken = test-token\n" + require.NoError(t, os.WriteFile(configFile, []byte(content), 0o600)) + + p := &profileMetadata{Name: "test-profile", Host: host} + p.Load(t.Context(), configFile, o.skipValidate) + return p +}