From 88136342087687bb5805d21d61d3e76b00c43765 Mon Sep 17 00:00:00 2001 From: Vladimir Vagaytsev <10628074+vvagaytsev@users.noreply.github.com> Date: Wed, 11 Dec 2024 11:38:40 +0100 Subject: [PATCH] refactor(cloud): extract some utilities out of `CloudApi` (#6707) * chore: fix some typos * refactor(cloud): move some static util methods outside `CloudApi` class * chore: update jsdocs * chore: use readonly fields where possible * refactor: rename class `CloudApi` -> `GardenCloudApi` * refactor: extract `apiFetch` machinery as inner http client class --- core/src/cli/cli.ts | 6 +- core/src/cloud/api.ts | 303 ++---------------- core/src/cloud/auth.ts | 89 ++++- core/src/cloud/http-client.ts | 212 ++++++++++++ core/src/cloud/workflow-lifecycle.ts | 2 +- .../commands/cloud/secrets/secrets-update.ts | 4 +- core/src/commands/login.ts | 8 +- core/src/commands/logout.ts | 7 +- core/src/garden.ts | 14 +- core/src/plugins/container/build.ts | 8 +- core/src/plugins/container/cloudbuilder.ts | 3 +- core/src/server/instance-manager.ts | 8 +- core/test/helpers/api.ts | 4 +- core/test/unit/src/cloud/api.ts | 22 +- core/test/unit/src/commands/login.ts | 50 ++- core/test/unit/src/commands/logout.ts | 75 ++--- core/test/unit/src/garden.ts | 4 +- 17 files changed, 421 insertions(+), 398 deletions(-) create mode 100644 core/src/cloud/http-client.ts diff --git a/core/src/cli/cli.ts b/core/src/cli/cli.ts index d2d7f5a1e1..5dc11c33ea 100644 --- a/core/src/cli/cli.ts +++ b/core/src/cli/cli.ts @@ -49,7 +49,7 @@ import { generateBasicDebugInfoReport } from "../commands/get/get-debug-info.js" import type { AnalyticsHandler } from "../analytics/analytics.js" import type { GardenPluginReference } from "../plugin/plugin.js" import type { CloudApiFactory } from "../cloud/api.js" -import { CloudApi, CloudApiTokenRefreshError, getGardenCloudDomain } from "../cloud/api.js" +import { GardenCloudApi, CloudApiTokenRefreshError, getGardenCloudDomain } from "../cloud/api.js" import { findProjectConfig } from "../config/base.js" import { pMemoizeDecorator } from "../lib/p-memoize.js" import { getCustomCommands } from "../commands/custom.js" @@ -98,7 +98,7 @@ export class GardenCli { public processRecord?: GardenProcess protected cloudApiFactory: CloudApiFactory - constructor({ plugins, initLogger = false, cloudApiFactory = CloudApi.factory }: GardenCliParams = {}) { + constructor({ plugins, initLogger = false, cloudApiFactory = GardenCloudApi.factory }: GardenCliParams = {}) { this.plugins = plugins || [] this.initLogger = initLogger this.cloudApiFactory = cloudApiFactory @@ -251,7 +251,7 @@ ${renderCommands(commands)} gardenInitLog?.info("Initializing...") // Init Cloud API (if applicable) - let cloudApi: CloudApi | undefined + let cloudApi: GardenCloudApi | undefined if (!command.noProject) { const config = await this.getProjectConfig(log, workingDir) const cloudDomain = getGardenCloudDomain(config?.domain) diff --git a/core/src/cloud/api.ts b/core/src/cloud/api.ts index 877090b053..874ff8bc23 100644 --- a/core/src/cloud/api.ts +++ b/core/src/cloud/api.ts @@ -6,15 +6,13 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import type { IncomingHttpHeaders } from "http" import ci from "ci-info" -import type { GotHeaders, GotJsonOptions, GotResponse } from "../util/http.js" -import { got, GotHttpError } from "../util/http.js" -import { CloudApiError, GardenError, InternalError } from "../exceptions.js" +import { GotHttpError } from "../util/http.js" +import { CloudApiError, GardenError } from "../exceptions.js" import type { Log } from "../logger/log-entry.js" import { DEFAULT_GARDEN_CLOUD_DOMAIN, gardenEnv } from "../constants.js" import { Cookie } from "tough-cookie" -import { cloneDeep, isObject, omit } from "lodash-es" +import { omit } from "lodash-es" import { dedent, deline } from "../util/string.js" import type { BaseResponse, @@ -34,22 +32,19 @@ import type { UpdateSecretResponse, } from "@garden-io/platform-api-types" import { getCloudDistributionName, getCloudLogSectionName } from "../util/cloud.js" -import { getPackageVersion } from "../util/util.js" import type { CommandInfo } from "../plugin-context.js" import type { ClientAuthToken, GlobalConfigStore } from "../config-store/global.js" -import { add } from "date-fns" import { LogLevel } from "../logger/logger.js" -import { makeAuthHeader } from "./auth.js" +import { getStoredAuthToken, saveAuthToken } from "./auth.js" import type { StringMap } from "../config/common.js" import { styles } from "../logger/styles.js" -import { HTTPError, RequestError } from "got" +import { HTTPError } from "got" import type { Garden } from "../garden.js" import type { ApiCommandError } from "../commands/cloud/helpers.js" import { enumerate } from "../util/enumerate.js" import queryString from "query-string" - -const gardenClientName = "garden-core" -const gardenClientVersion = getPackageVersion() +import type { ApiFetchOptions } from "./http-client.js" +import { GardenCloudHttpClient } from "./http-client.js" export class CloudApiDuplicateProjectsError extends CloudApiError {} @@ -59,32 +54,10 @@ function extractErrorMessageBodyFromGotError(error: any): error is GotHttpError return error?.response?.body?.message } -function stripLeadingSlash(str: string) { - return str.replace(/^\/+/, "") -} - -// This is to prevent Unhandled Promise Rejections in got -// See: https://github.com/sindresorhus/got/issues/1489#issuecomment-805485731 -function isGotResponseOk(response: GotResponse) { - const { statusCode } = response - const limitStatusCode = response.request.options.followRedirect ? 299 : 399 - - return (statusCode >= 200 && statusCode <= limitStatusCode) || statusCode === 304 -} - const refreshThreshold = 10 // Threshold (in seconds) subtracted to jwt validity when checking if a refresh is needed const secretsPageLimit = 100 -export interface ApiFetchParams { - headers: GotHeaders - method: "GET" | "POST" | "PUT" | "PATCH" | "HEAD" | "DELETE" - retry: boolean - retryDescription?: string - maxRetries?: number - body?: any -} - interface BulkOperationResult { results: SecretResult[] errors: ApiCommandError[] @@ -107,30 +80,12 @@ export interface BulkUpdateSecretRequest { secrets: SingleUpdateSecretRequest[] } -export interface ApiFetchOptions { - headers?: GotHeaders - /** - * True by default except for api.post (where retry = true must explicitly be passed, since retries aren't always - * safe / desirable for such requests). - */ - retry?: boolean - maxRetries?: number - /** - * An optional prefix to use for retry error messages. - */ - retryDescription?: string -} - export interface AuthTokenResponse { token: string refreshToken: string tokenValidity: number } -export type ApiFetchResponse = T & { - headers: IncomingHttpHeaders -} - // TODO: Read this from the `api-types` package once the session registration logic has been released in Cloud. export interface CloudSessionResponse { environmentId: string @@ -139,7 +94,7 @@ export interface CloudSessionResponse { } export interface CloudSession extends CloudSessionResponse { - api: CloudApi + api: GardenCloudApi id: string projectId: string } @@ -209,30 +164,32 @@ export interface CloudApiFactoryParams { skipLogging?: boolean } -export type CloudApiFactory = (params: CloudApiFactoryParams) => Promise +export type CloudApiFactory = (params: CloudApiFactoryParams) => Promise /** - * The Enterprise API client. + * The Garden Cloud / Enterprise API client. * - * Can only be initialized if the user is actually logged in. Includes a handful of static helper methods - * for cases where the user is not logged in (e.g. the login method itself). + * Can only be initialized if the user is actually logged in. */ -export class CloudApi { +export class GardenCloudApi { private intervalId: NodeJS.Timeout | null = null - private intervalMsec = 4500 // Refresh interval in ms, it needs to be less than refreshThreshold/2 - private apiPrefix = "api" + private readonly intervalMsec = 4500 // Refresh interval in ms, it needs to be less than refreshThreshold/2 private _profile?: GetProfileResponse["data"] + private readonly httpClient: GardenCloudHttpClient + private projects: Map // keyed by project ID private registeredSessions: Map // keyed by session ID - private log: Log + private readonly log: Log public readonly domain: string public readonly distroName: string - private globalConfigStore: GlobalConfigStore + private readonly globalConfigStore: GlobalConfigStore - constructor({ log, domain, globalConfigStore }: { log: Log; domain: string; globalConfigStore: GlobalConfigStore }) { + constructor(params: { log: Log; domain: string; globalConfigStore: GlobalConfigStore }) { + const { log, domain, globalConfigStore } = params this.log = log + this.httpClient = new GardenCloudHttpClient(params) this.domain = domain this.distroName = getCloudDistributionName(domain) this.globalConfigStore = globalConfigStore @@ -257,7 +214,7 @@ export class CloudApi { cloudFactoryLog.debug("Initializing Garden Cloud API client.") - const token = await CloudApi.getStoredAuthToken(log, globalConfigStore, cloudDomain) + const token = await getStoredAuthToken(log, globalConfigStore, cloudDomain) if (!token && !gardenEnv.GARDEN_AUTH_TOKEN) { log.debug( @@ -266,7 +223,7 @@ export class CloudApi { return } - const api = new CloudApi({ log, domain: cloudDomain, globalConfigStore }) + const api = new GardenCloudApi({ log, domain: cloudDomain, globalConfigStore }) const tokenIsValid = await api.checkClientAuthToken() cloudFactoryLog.debug("Authorizing...") @@ -298,88 +255,6 @@ export class CloudApi { return api } - static async saveAuthToken( - log: Log, - globalConfigStore: GlobalConfigStore, - tokenResponse: AuthTokenResponse, - domain: string - ) { - const distroName = getCloudDistributionName(domain) - - if (!tokenResponse.token) { - const errMsg = deline` - Received a null/empty client auth token while logging in. This indicates that either your user account hasn't - yet been created in ${distroName}, or that there's a problem with your account's VCS username / login - credentials. - ` - throw new CloudApiError({ message: errMsg }) - } - try { - const validityMs = tokenResponse.tokenValidity || 604800000 - await globalConfigStore.set("clientAuthTokens", domain, { - token: tokenResponse.token, - refreshToken: tokenResponse.refreshToken, - validity: add(new Date(), { seconds: validityMs / 1000 }), - }) - log.debug("Saved client auth token to config store") - } catch (error) { - const redactedResponse = cloneDeep(tokenResponse) - if (redactedResponse.refreshToken) { - redactedResponse.refreshToken = "" - } - if (redactedResponse.token) { - redactedResponse.token = "" - } - // If we get here, this is a bug. - throw InternalError.wrapError( - error, - dedent` - An error occurred while saving client auth token to local config db. - - Token response: ${JSON.stringify(redactedResponse)}` - ) - } - } - - /** - * Returns the full client auth token from the local DB. - * - * In the inconsistent/erroneous case of more than one auth token existing in the local store, picks the first auth - * token and deletes all others. - */ - static async getStoredAuthToken(log: Log, globalConfigStore: GlobalConfigStore, domain: string) { - log.silly(() => `Retrieving client auth token from config store`) - return globalConfigStore.get("clientAuthTokens", domain) - } - - /** - * If a persisted client auth token was found, or if the GARDEN_AUTH_TOKEN environment variable is present, - * returns it. Returns null otherwise. - * - * Note that the GARDEN_AUTH_TOKEN environment variable takes precedence over a persisted auth token if both are - * present. - */ - static async getAuthToken( - log: Log, - globalConfigStore: GlobalConfigStore, - domain: string - ): Promise { - const tokenFromEnv = gardenEnv.GARDEN_AUTH_TOKEN - if (tokenFromEnv) { - log.silly(() => "Read client auth token from env") - return tokenFromEnv - } - return (await CloudApi.getStoredAuthToken(log, globalConfigStore, domain))?.token - } - - /** - * If a persisted client auth token exists, deletes it. - */ - static async clearAuthToken(log: Log, globalConfigStore: GlobalConfigStore, domain: string) { - await globalConfigStore.delete("clientAuthTokens", domain) - log.debug("Cleared persisted auth token (if any)") - } - private startInterval() { this.log.debug({ msg: `Will run refresh function every ${this.intervalMsec} ms.` }) this.intervalId = setInterval(() => { @@ -500,7 +375,7 @@ export class CloudApi { refreshToken: rt.value || "", tokenValidity: res.data.jwtValidity, } - await CloudApi.saveAuthToken(this.log, this.globalConfigStore, tokenObj, this.domain) + await saveAuthToken(this.log, this.globalConfigStore, tokenObj, this.domain) } catch (err) { if (!(err instanceof GotHttpError)) { throw err @@ -516,131 +391,9 @@ export class CloudApi { } } - private async apiFetch(path: string, params: ApiFetchParams): Promise> { - const { method, headers, retry, retryDescription } = params - this.log.silly(() => `Calling Cloud API with ${method} ${path}`) - const token = await CloudApi.getAuthToken(this.log, this.globalConfigStore, this.domain) - // TODO add more logging details - const requestObj = { - method, - headers: { - "x-garden-client-version": gardenClientVersion, - "x-garden-client-name": gardenClientName, - ...headers, - ...makeAuthHeader(token || ""), - }, - json: params.body, - } - - const requestOptions: GotJsonOptions = { - ...requestObj, - responseType: "json", - } - - const url = new URL(`/${this.apiPrefix}/${stripLeadingSlash(path)}`, this.domain) - - if (retry) { - let retryLog: Log | undefined = undefined - const retryLimit = params.maxRetries || 3 - requestOptions.retry = { - methods: ["GET", "POST", "PUT", "DELETE"], // We explicitly include the POST method if `retry = true`. - statusCodes: [ - 408, // Request Timeout - // 413, // Payload Too Large: No use in retrying. - 429, // Too Many Requests - // 500, // Internal Server Error: Generally not safe to retry without potentially creating duplicate data. - 502, // Bad Gateway - 503, // Service Unavailable - 504, // Gateway Timeout - - // Cloudflare-specific status codes - 521, // Web Server Is Down - 522, // Connection Timed Out - 524, // A Timeout Occurred - ], - limit: retryLimit, - } - requestOptions.hooks = { - beforeRetry: [ - (error, retryCount) => { - if (error) { - // Intentionally skipping search params in case they contain tokens or sensitive data. - const href = url.origin + url.pathname - const description = retryDescription || `Request` - retryLog = retryLog || this.log.createLog({ fixLevel: LogLevel.debug }) - const statusCodeDescription = error.code ? ` (status code ${error.code})` : `` - retryLog.info(deline` - ${description} failed with error ${error.message}${statusCodeDescription}, - retrying (${retryCount}/${retryLimit}) (url=${href}) - `) - } - }, - ], - // See: https://github.com/sindresorhus/got/issues/1489#issuecomment-805485731 - afterResponse: [ - (response) => { - if (isGotResponseOk(response)) { - response.request.destroy() - } - - return response - }, - ], - } - } else { - requestOptions.retry = undefined // Disables retry - } - - try { - const res = await got(url.href, requestOptions) - - if (!isObject(res.body)) { - throw new CloudApiError({ - message: dedent` - Unexpected response from Garden Cloud: Expected object. - - Request ID: ${res.headers["x-request-id"]} - Request url: ${url} - Response code: ${res?.statusCode} - Response body: ${JSON.stringify(res?.body)} - `, - responseStatusCode: res?.statusCode, - }) - } - - return { - ...res.body, - headers: res.headers, - } - } catch (e: unknown) { - if (!(e instanceof RequestError)) { - throw e - } - - // The assumption here is that Garden Enterprise is self-hosted. - // This error should only be thrown if the Garden Enterprise instance is not hosted by us (i.e. Garden Inc.) - if (e.code === "DEPTH_ZERO_SELF_SIGNED_CERT" && getCloudDistributionName(this.domain) === "Garden Enterprise") { - throw new CloudApiError({ - message: dedent` - SSL error when communicating to Garden Cloud: ${e} - - If your Garden Cloud instance is self-hosted and you are using a self-signed certificate, Garden will not trust your system's CA certificates. - - In case if you need to trust extra certificate authorities, consider exporting the environment variable NODE_EXTRA_CA_CERTS. See https://nodejs.org/api/cli.html#node_extra_ca_certsfile - - Request url: ${url} - Error code: ${e.code} - `, - }) - } - - throw e - } - } - async get(path: string, opts: ApiFetchOptions = {}) { const { headers, retry, retryDescription, maxRetries } = opts - return this.apiFetch(path, { + return this.httpClient.apiFetch(path, { method: "GET", headers: headers || {}, retry: retry !== false, // defaults to true unless false is explicitly passed @@ -651,7 +404,7 @@ export class CloudApi { async delete(path: string, opts: ApiFetchOptions = {}) { const { headers, retry, retryDescription, maxRetries } = opts - return await this.apiFetch(path, { + return await this.httpClient.apiFetch(path, { method: "DELETE", headers: headers || {}, retry: retry !== false, // defaults to true unless false is explicitly passed @@ -662,7 +415,7 @@ export class CloudApi { async post(path: string, opts: ApiFetchOptions & { body?: any } = {}) { const { body, headers, retry, retryDescription, maxRetries } = opts - return this.apiFetch(path, { + return this.httpClient.apiFetch(path, { method: "POST", body: body || {}, headers: headers || {}, @@ -674,7 +427,7 @@ export class CloudApi { async put(path: string, opts: ApiFetchOptions & { body?: any } = {}) { const { body, headers, retry, retryDescription, maxRetries } = opts - return this.apiFetch(path, { + return this.httpClient.apiFetch(path, { method: "PUT", body: body || {}, headers: headers || {}, @@ -1058,7 +811,7 @@ type RegisterCloudBuilderBuildResponseV2 = { } type UnsupportedRegisterCloudBuilderBuildResponse = { data: { - version: "unsupported" // using unknown here overpowers the compund type + version: "unsupported" // using unknown here overpowers the compound type } } type RegisterCloudBuilderBuildResponse = diff --git a/core/src/cloud/auth.ts b/core/src/cloud/auth.ts index 3f32e7a357..4c5d9701a9 100644 --- a/core/src/cloud/auth.ts +++ b/core/src/cloud/auth.ts @@ -15,8 +15,95 @@ import Router from "koa-router" import getPort from "get-port" import type { Log } from "../logger/log-entry.js" import type { AuthTokenResponse } from "./api.js" -import { isArray } from "lodash-es" +import { cloneDeep, isArray } from "lodash-es" import { gardenEnv } from "../constants.js" +import type { GlobalConfigStore } from "../config-store/global.js" +import { getCloudDistributionName } from "../util/cloud.js" +import { dedent, deline } from "../util/string.js" +import { CloudApiError, InternalError } from "../exceptions.js" +import { add } from "date-fns" + +export async function saveAuthToken( + log: Log, + globalConfigStore: GlobalConfigStore, + tokenResponse: AuthTokenResponse, + domain: string +) { + const distroName = getCloudDistributionName(domain) + + if (!tokenResponse.token) { + const errMsg = deline` + Received a null/empty client auth token while logging in. This indicates that either your user account hasn't + yet been created in ${distroName}, or that there's a problem with your account's VCS username / login + credentials. + ` + throw new CloudApiError({ message: errMsg }) + } + try { + const validityMs = tokenResponse.tokenValidity || 604800000 + await globalConfigStore.set("clientAuthTokens", domain, { + token: tokenResponse.token, + refreshToken: tokenResponse.refreshToken, + validity: add(new Date(), { seconds: validityMs / 1000 }), + }) + log.debug("Saved client auth token to config store") + } catch (error) { + const redactedResponse = cloneDeep(tokenResponse) + if (redactedResponse.refreshToken) { + redactedResponse.refreshToken = "" + } + if (redactedResponse.token) { + redactedResponse.token = "" + } + // If we get here, this is a bug. + throw InternalError.wrapError( + error, + dedent` + An error occurred while saving client auth token to local config db. + + Token response: ${JSON.stringify(redactedResponse)}` + ) + } +} + +/** + * Returns the full client auth token from the local DB. + * + * In the inconsistent/erroneous case of more than one auth token existing in the local store, picks the first auth + * token and deletes all others. + */ +export async function getStoredAuthToken(log: Log, globalConfigStore: GlobalConfigStore, domain: string) { + log.silly(() => `Retrieving client auth token from config store`) + return globalConfigStore.get("clientAuthTokens", domain) +} + +/** + * If a persisted client auth token was found, or if the GARDEN_AUTH_TOKEN environment variable is present, + * returns it. Returns null otherwise. + * + * Note that the GARDEN_AUTH_TOKEN environment variable takes precedence over a persisted auth token if both are + * present. + */ +export async function getAuthToken( + log: Log, + globalConfigStore: GlobalConfigStore, + domain: string +): Promise { + const tokenFromEnv = gardenEnv.GARDEN_AUTH_TOKEN + if (tokenFromEnv) { + log.silly(() => "Read client auth token from env") + return tokenFromEnv + } + return (await getStoredAuthToken(log, globalConfigStore, domain))?.token +} + +/** + * If a persisted client auth token exists, deletes it. + */ +export async function clearAuthToken(log: Log, globalConfigStore: GlobalConfigStore, domain: string) { + await globalConfigStore.delete("clientAuthTokens", domain) + log.debug("Cleared persisted auth token (if any)") +} // If a GARDEN_AUTH_TOKEN is present and Garden is NOT running from a workflow runner pod, // switch to ci-token authentication method. diff --git a/core/src/cloud/http-client.ts b/core/src/cloud/http-client.ts new file mode 100644 index 0000000000..0c16e5aa14 --- /dev/null +++ b/core/src/cloud/http-client.ts @@ -0,0 +1,212 @@ +/* + * Copyright (C) 2018-2024 Garden Technologies, Inc. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import type { IncomingHttpHeaders } from "http" +import type { GotHeaders, GotJsonOptions, GotResponse } from "../util/http.js" +import { got } from "../util/http.js" +import { CloudApiError } from "../exceptions.js" +import type { Log } from "../logger/log-entry.js" +import { isObject } from "lodash-es" +import { dedent, deline } from "../util/string.js" +import { getCloudDistributionName } from "../util/cloud.js" +import { getPackageVersion } from "../util/util.js" +import type { GlobalConfigStore } from "../config-store/global.js" +import { LogLevel } from "../logger/logger.js" +import { getAuthToken, makeAuthHeader } from "./auth.js" +import { RequestError } from "got" + +const gardenClientName = "garden-core" +const gardenClientVersion = getPackageVersion() + +export interface ApiFetchParams { + headers: GotHeaders + method: "GET" | "POST" | "PUT" | "PATCH" | "HEAD" | "DELETE" + retry: boolean + retryDescription?: string + maxRetries?: number + body?: any +} + +export interface ApiFetchOptions { + headers?: GotHeaders + /** + * True by default except for api.post (where retry = true must explicitly be passed, since retries aren't always + * safe / desirable for such requests). + */ + retry?: boolean + maxRetries?: number + /** + * An optional prefix to use for retry error messages. + */ + retryDescription?: string +} + +export type ApiFetchResponse = T & { + headers: IncomingHttpHeaders +} + +function stripLeadingSlash(str: string) { + return str.replace(/^\/+/, "") +} + +// This is to prevent Unhandled Promise Rejections in got +// See: https://github.com/sindresorhus/got/issues/1489#issuecomment-805485731 +function isGotResponseOk(response: GotResponse) { + const { statusCode } = response + const limitStatusCode = response.request.options.followRedirect ? 299 : 399 + + return (statusCode >= 200 && statusCode <= limitStatusCode) || statusCode === 304 +} + +export interface Secret { + name: string + value: string +} + +/** + * The Garden Cloud / Enterprise API client. + * + * Can only be initialized if the user is actually logged in. + */ +export class GardenCloudHttpClient { + private readonly apiPrefix = "api" + private readonly globalConfigStore: GlobalConfigStore + private readonly log: Log + + public readonly domain: string + public readonly distroName: string + + constructor({ log, domain, globalConfigStore }: { log: Log; domain: string; globalConfigStore: GlobalConfigStore }) { + this.log = log + this.domain = domain + this.distroName = getCloudDistributionName(domain) + this.globalConfigStore = globalConfigStore + } + + async apiFetch(path: string, params: ApiFetchParams): Promise> { + const { method, headers, retry, retryDescription } = params + this.log.silly(() => `Calling Cloud API with ${method} ${path}`) + const token = await getAuthToken(this.log, this.globalConfigStore, this.domain) + // TODO add more logging details + const requestObj = { + method, + headers: { + "x-garden-client-version": gardenClientVersion, + "x-garden-client-name": gardenClientName, + ...headers, + ...makeAuthHeader(token || ""), + }, + json: params.body, + } + + const requestOptions: GotJsonOptions = { + ...requestObj, + responseType: "json", + } + + const url = new URL(`/${this.apiPrefix}/${stripLeadingSlash(path)}`, this.domain) + + if (retry) { + let retryLog: Log | undefined = undefined + const retryLimit = params.maxRetries || 3 + requestOptions.retry = { + methods: ["GET", "POST", "PUT", "DELETE"], // We explicitly include the POST method if `retry = true`. + statusCodes: [ + 408, // Request Timeout + // 413, // Payload Too Large: No use in retrying. + 429, // Too Many Requests + // 500, // Internal Server Error: Generally not safe to retry without potentially creating duplicate data. + 502, // Bad Gateway + 503, // Service Unavailable + 504, // Gateway Timeout + + // Cloudflare-specific status codes + 521, // Web Server Is Down + 522, // Connection Timed Out + 524, // A Timeout Occurred + ], + limit: retryLimit, + } + requestOptions.hooks = { + beforeRetry: [ + (error, retryCount) => { + if (error) { + // Intentionally skipping search params in case they contain tokens or sensitive data. + const href = url.origin + url.pathname + const description = retryDescription || `Request` + retryLog = retryLog || this.log.createLog({ fixLevel: LogLevel.debug }) + const statusCodeDescription = error.code ? ` (status code ${error.code})` : `` + retryLog.info(deline` + ${description} failed with error ${error.message}${statusCodeDescription}, + retrying (${retryCount}/${retryLimit}) (url=${href}) + `) + } + }, + ], + // See: https://github.com/sindresorhus/got/issues/1489#issuecomment-805485731 + afterResponse: [ + (response) => { + if (isGotResponseOk(response)) { + response.request.destroy() + } + + return response + }, + ], + } + } else { + requestOptions.retry = undefined // Disables retry + } + + try { + const res = await got(url.href, requestOptions) + + if (!isObject(res.body)) { + throw new CloudApiError({ + message: dedent` + Unexpected response from Garden Cloud: Expected object. + + Request ID: ${res.headers["x-request-id"]} + Request url: ${url} + Response code: ${res?.statusCode} + Response body: ${JSON.stringify(res?.body)} + `, + responseStatusCode: res?.statusCode, + }) + } + + return { + ...res.body, + headers: res.headers, + } + } catch (e: unknown) { + if (!(e instanceof RequestError)) { + throw e + } + + // The assumption here is that Garden Enterprise is self-hosted. + // This error should only be thrown if the Garden Enterprise instance is not hosted by us (i.e. Garden Inc.) + if (e.code === "DEPTH_ZERO_SELF_SIGNED_CERT" && getCloudDistributionName(this.domain) === "Garden Enterprise") { + throw new CloudApiError({ + message: dedent` + SSL error when communicating to Garden Cloud: ${e} + + If your Garden Cloud instance is self-hosted and you are using a self-signed certificate, Garden will not trust your system's CA certificates. + + In case if you need to trust extra certificate authorities, consider exporting the environment variable NODE_EXTRA_CA_CERTS. See https://nodejs.org/api/cli.html#node_extra_ca_certsfile + + Request url: ${url} + Error code: ${e.code} + `, + }) + } + + throw e + } + } +} diff --git a/core/src/cloud/workflow-lifecycle.ts b/core/src/cloud/workflow-lifecycle.ts index f22237109d..7a05daf981 100644 --- a/core/src/cloud/workflow-lifecycle.ts +++ b/core/src/cloud/workflow-lifecycle.ts @@ -12,7 +12,7 @@ import type { Log } from "../logger/log-entry.js" import { CloudApiError } from "../exceptions.js" import { gardenEnv } from "../constants.js" import type { Garden } from "../garden.js" -import type { ApiFetchResponse } from "./api.js" +import type { ApiFetchResponse } from "./http-client.js" import type { CreateWorkflowRunResponse } from "@garden-io/platform-api-types" import { dedent } from "../util/string.js" import { GotHttpError } from "../util/http.js" diff --git a/core/src/commands/cloud/secrets/secrets-update.ts b/core/src/commands/cloud/secrets/secrets-update.ts index eaf10b5352..aa718edb49 100644 --- a/core/src/commands/cloud/secrets/secrets-update.ts +++ b/core/src/commands/cloud/secrets/secrets-update.ts @@ -22,7 +22,7 @@ import { getEnvironmentByNameOrThrow } from "./secret-helpers.js" import type { BulkCreateSecretRequest, BulkUpdateSecretRequest, - CloudApi, + GardenCloudApi, Secret, SingleUpdateSecretRequest, } from "../../../cloud/api.js" @@ -188,7 +188,7 @@ export class SecretsUpdateCommand extends Command { } async function prepareSecretsRequests(params: { - api: CloudApi + api: GardenCloudApi environmentId: string | undefined environmentName: string | undefined log: Log diff --git a/core/src/commands/login.ts b/core/src/commands/login.ts index 0f06b69aa6..f276108414 100644 --- a/core/src/commands/login.ts +++ b/core/src/commands/login.ts @@ -11,10 +11,10 @@ import { Command } from "./base.js" import { printHeader } from "../logger/util.js" import dedent from "dedent" import type { AuthTokenResponse } from "../cloud/api.js" -import { CloudApi, getGardenCloudDomain } from "../cloud/api.js" +import { GardenCloudApi, getGardenCloudDomain } from "../cloud/api.js" import type { Log } from "../logger/log-entry.js" import { ConfigurationError, TimeoutError, InternalError, CloudApiError } from "../exceptions.js" -import { AuthRedirectServer } from "../cloud/auth.js" +import { AuthRedirectServer, saveAuthToken } from "../cloud/auth.js" import type { EventBus } from "../events/events.js" import type { ProjectConfig } from "../config/project.js" import { findProjectConfig } from "../config/base.js" @@ -88,7 +88,7 @@ export class LoginCommand extends Command<{}, Opts> { const cloudDomain: string = getGardenCloudDomain(projectConfig?.domain) try { - const cloudApi = await CloudApi.factory({ log, cloudDomain, skipLogging: true, globalConfigStore }) + const cloudApi = await GardenCloudApi.factory({ log, cloudDomain, skipLogging: true, globalConfigStore }) if (cloudApi) { log.success({ msg: `You're already logged in to ${cloudDomain}.` }) @@ -103,7 +103,7 @@ export class LoginCommand extends Command<{}, Opts> { log.info({ msg: `Logging in to ${cloudDomain}...` }) const tokenResponse = await login(log, cloudDomain, garden.events) - await CloudApi.saveAuthToken(log, globalConfigStore, tokenResponse, cloudDomain) + await saveAuthToken(log, globalConfigStore, tokenResponse, cloudDomain) log.success({ msg: `Successfully logged in to ${cloudDomain}.`, showDuration: false }) return {} diff --git a/core/src/commands/logout.ts b/core/src/commands/logout.ts index 89f7382e0a..7089fa689f 100644 --- a/core/src/commands/logout.ts +++ b/core/src/commands/logout.ts @@ -9,13 +9,14 @@ import type { CommandParams, CommandResult } from "./base.js" import { Command } from "./base.js" import { printHeader } from "../logger/util.js" -import { CloudApi, getGardenCloudDomain } from "../cloud/api.js" +import { GardenCloudApi, getGardenCloudDomain } from "../cloud/api.js" import { getCloudDistributionName } from "../util/cloud.js" import { dedent, deline } from "../util/string.js" import { ConfigurationError } from "../exceptions.js" import type { ProjectConfig } from "../config/project.js" import { findProjectConfig } from "../config/base.js" import { BooleanParameter } from "../cli/params.js" +import { clearAuthToken } from "../cloud/auth.js" export const logoutOpts = { "disable-project-check": new BooleanParameter({ @@ -73,7 +74,7 @@ export class LogOutCommand extends Command<{}, Opts> { return {} } - const cloudApi = await CloudApi.factory({ + const cloudApi = await GardenCloudApi.factory({ log, cloudDomain, skipLogging: true, @@ -92,7 +93,7 @@ export class LogOutCommand extends Command<{}, Opts> { ` log.warn(msg) } finally { - await CloudApi.clearAuthToken(log, garden.globalConfigStore, cloudDomain) + await clearAuthToken(log, garden.globalConfigStore, cloudDomain) log.success(`Successfully logged out from ${cloudDomain}.`) } return {} diff --git a/core/src/garden.ts b/core/src/garden.ts index 70f23f4f00..cd8b1f3e29 100644 --- a/core/src/garden.ts +++ b/core/src/garden.ts @@ -118,7 +118,7 @@ import { RemoteSourceConfigContext, TemplatableConfigContext, } from "./config/template-contexts/project.js" -import type { CloudApi, CloudProject } from "./cloud/api.js" +import type { GardenCloudApi, CloudProject } from "./cloud/api.js" import { getGardenCloudDomain } from "./cloud/api.js" import { OutputConfigContext } from "./config/template-contexts/module.js" import { ProviderConfigContext } from "./config/template-contexts/provider.js" @@ -191,7 +191,7 @@ export interface GardenOpts { plugins?: RegisterPluginParam[] sessionId?: string variableOverrides?: PrimitiveMap - cloudApi?: CloudApi + cloudApi?: GardenCloudApi } export interface GardenParams { @@ -228,7 +228,7 @@ export interface GardenParams { username: string | undefined workingCopyId: string forceRefresh?: boolean - cloudApi?: CloudApi | null + cloudApi?: GardenCloudApi | null projectApiVersion: ProjectConfig["apiVersion"] } @@ -283,7 +283,7 @@ export class Garden { public readonly configTemplates: { [name: string]: ConfigTemplateConfig } private actionTypeBases: ActionTypeMap[]> private emittedWarnings: Set - public cloudApi: CloudApi | null + public cloudApi: GardenCloudApi | null public readonly production: boolean public readonly projectRoot: string @@ -503,7 +503,7 @@ export class Garden { return Object.assign(Object.create(Object.getPrototypeOf(this)), this) } - cloneForCommand(sessionId: string, cloudApi?: CloudApi): Garden { + cloneForCommand(sessionId: string, cloudApi?: GardenCloudApi): Garden { // Make an instance clone to override anything that needs to be scoped to a specific command run // TODO: this could be made more elegant const garden = this.clone() @@ -2119,7 +2119,7 @@ async function prepareCloud({ environmentName, commandName, }: { - cloudApi: CloudApi | null + cloudApi: GardenCloudApi | null config: ProjectConfig log: Log projectRoot: string @@ -2194,7 +2194,7 @@ async function getCloudProject({ projectRoot, projectName, }: { - cloudApi: CloudApi + cloudApi: GardenCloudApi config: ProjectConfig log: Log isCommunityEdition: boolean diff --git a/core/src/plugins/container/build.ts b/core/src/plugins/container/build.ts index f03598fe68..c0be6732ef 100644 --- a/core/src/plugins/container/build.ts +++ b/core/src/plugins/container/build.ts @@ -225,7 +225,7 @@ async function buildContainerInCloudBuilder(params: { log: ActionLog ctx: PluginContext }) { - const cloudbuilderStats = { + const cloudBuilderStats = { totalLayers: 0, layersCached: 0, } @@ -234,9 +234,9 @@ async function buildContainerInCloudBuilder(params: { params.outputStream.on("data", (line: Buffer) => { const logLine = line.toString() if (BUILDKIT_LAYER_REGEX.test(logLine)) { - cloudbuilderStats.totalLayers++ + cloudBuilderStats.totalLayers++ } else if (BUILDKIT_LAYER_CACHED_REGEX.test(logLine)) { - cloudbuilderStats.layersCached++ + cloudBuilderStats.layersCached++ } }) @@ -257,7 +257,7 @@ async function buildContainerInCloudBuilder(params: { name: `build.${params.action.name}`, }) log.success( - `${styles.bold("Accelerated by Garden Cloud Builder")} (${cloudbuilderStats.layersCached}/${cloudbuilderStats.totalLayers} layers cached)` + `${styles.bold("Accelerated by Garden Cloud Builder")} (${cloudBuilderStats.layersCached}/${cloudBuilderStats.totalLayers} layers cached)` ) return res diff --git a/core/src/plugins/container/cloudbuilder.ts b/core/src/plugins/container/cloudbuilder.ts index a243ce0517..30f132696c 100644 --- a/core/src/plugins/container/cloudbuilder.ts +++ b/core/src/plugins/container/cloudbuilder.ts @@ -14,7 +14,6 @@ import dedent from "dedent" import { styles } from "../../logger/styles.js" import type { KubernetesPluginContext } from "../kubernetes/config.js" import fsExtra from "fs-extra" -const { mkdirp, rm, writeFile, stat } = fsExtra import { basename, dirname, join } from "path" import { tmpdir } from "node:os" import type { CloudBuilderAvailabilityV2, CloudBuilderAvailableV2 } from "../../cloud/api.js" @@ -31,6 +30,8 @@ import { hashString } from "../../util/util.js" import { stableStringify } from "../../util/string.js" import { homedir } from "os" +const { mkdirp, rm, writeFile, stat } = fsExtra + const generateKeyPair = promisify(crypto.generateKeyPair) type MtlsKeyPair = { diff --git a/core/src/server/instance-manager.ts b/core/src/server/instance-manager.ts index ff20faffd9..9f8814d21a 100644 --- a/core/src/server/instance-manager.ts +++ b/core/src/server/instance-manager.ts @@ -12,7 +12,7 @@ import { Autocompleter } from "../cli/autocomplete.js" import { parseCliVarFlags } from "../cli/helpers.js" import type { ParameterObject, ParameterValues } from "../cli/params.js" import type { CloudApiFactory, CloudApiFactoryParams } from "../cloud/api.js" -import { CloudApi, getGardenCloudDomain } from "../cloud/api.js" +import { GardenCloudApi, getGardenCloudDomain } from "../cloud/api.js" import type { Command } from "../commands/base.js" import { getBuiltinCommands, flattenCommands } from "../commands/commands.js" import { getCustomCommands } from "../commands/custom.js" @@ -72,7 +72,7 @@ export class GardenInstanceManager { private instances: Map private projectRoots: Map private cloudApiFactory: CloudApiFactory - private cloudApis: Map + private cloudApis: Map private lastRequested: Map private lock: AsyncLock private builtinCommands: Command[] @@ -107,7 +107,7 @@ export class GardenInstanceManager { this.lastRequested = new Map() this.defaultOpts = defaultOpts || {} this.plugins = plugins - this.cloudApiFactory = cloudApiFactory || CloudApi.factory + this.cloudApiFactory = cloudApiFactory || GardenCloudApi.factory this.serveCommand = serveCommand this.events = new EventBus() @@ -357,7 +357,7 @@ export class GardenInstanceManager { environmentString?: string sessionId: string }) { - let cloudApi: CloudApi | undefined + let cloudApi: GardenCloudApi | undefined if (!command?.noProject) { cloudApi = await this.getCloudApi({ diff --git a/core/test/helpers/api.ts b/core/test/helpers/api.ts index 890fb36ee3..89a7c27764 100644 --- a/core/test/helpers/api.ts +++ b/core/test/helpers/api.ts @@ -7,7 +7,7 @@ */ import type { CloudOrganization, CloudProject, GetSecretsParams } from "../../src/cloud/api.js" -import { CloudApi } from "../../src/cloud/api.js" +import { GardenCloudApi } from "../../src/cloud/api.js" import type { Log } from "../../src/logger/log-entry.js" import { GlobalConfigStore } from "../../src/config-store/global.js" import { uuidv4 } from "../../src/util/random.js" @@ -22,7 +22,7 @@ export const apiRemoteOriginUrl = "git@github.com:garden-io/garden.git" export const apiProjectName = "95048f63dc14db38ed4138ffb6ff89992abdc19b8c899099c52a94f8fcc0390eec6480385cfa5014f84c0a14d4984825ce3bf25db1386d2b5382b936899df675" -export class FakeCloudApi extends CloudApi { +export class FakeCloudApi extends GardenCloudApi { static override async factory(params: { log: Log; skipLogging?: boolean }) { return new FakeCloudApi({ log: params.log, diff --git a/core/test/unit/src/cloud/api.ts b/core/test/unit/src/cloud/api.ts index da67ae0b5f..f3a79c4c71 100644 --- a/core/test/unit/src/cloud/api.ts +++ b/core/test/unit/src/cloud/api.ts @@ -9,19 +9,19 @@ import { expect } from "chai" import { getRootLogger } from "../../../../src/logger/logger.js" import { gardenEnv } from "../../../../src/constants.js" -import { CloudApi } from "../../../../src/cloud/api.js" import { uuidv4 } from "../../../../src/util/random.js" import { randomString } from "../../../../src/util/string.js" import { GlobalConfigStore } from "../../../../src/config-store/global.js" +import { clearAuthToken, getAuthToken, saveAuthToken } from "../../../../src/cloud/auth.js" -describe("CloudApi", () => { +describe("GardenCloudApi", () => { const log = getRootLogger().createLog() const domain = "https://garden." + randomString() const globalConfigStore = new GlobalConfigStore() describe("getAuthToken", () => { it("should return null when no auth token is present", async () => { - const savedToken = await CloudApi.getAuthToken(log, globalConfigStore, domain) + const savedToken = await getAuthToken(log, globalConfigStore, domain) expect(savedToken).to.be.undefined }) @@ -31,8 +31,8 @@ describe("CloudApi", () => { refreshToken: uuidv4(), tokenValidity: 9999, } - await CloudApi.saveAuthToken(log, globalConfigStore, testToken, domain) - const savedToken = await CloudApi.getAuthToken(log, globalConfigStore, domain) + await saveAuthToken(log, globalConfigStore, testToken, domain) + const savedToken = await getAuthToken(log, globalConfigStore, domain) expect(savedToken).to.eql(testToken.token) }) @@ -41,7 +41,7 @@ describe("CloudApi", () => { const testToken = "token-from-env" gardenEnv.GARDEN_AUTH_TOKEN = testToken try { - const savedToken = await CloudApi.getAuthToken(log, globalConfigStore, domain) + const savedToken = await getAuthToken(log, globalConfigStore, domain) expect(savedToken).to.eql(testToken) } finally { gardenEnv.GARDEN_AUTH_TOKEN = tokenBackup @@ -56,15 +56,15 @@ describe("CloudApi", () => { refreshToken: uuidv4(), tokenValidity: 9999, } - await CloudApi.saveAuthToken(log, globalConfigStore, testToken, domain) - await CloudApi.clearAuthToken(log, globalConfigStore, domain) - const savedToken = await CloudApi.getAuthToken(log, globalConfigStore, domain) + await saveAuthToken(log, globalConfigStore, testToken, domain) + await clearAuthToken(log, globalConfigStore, domain) + const savedToken = await getAuthToken(log, globalConfigStore, domain) expect(savedToken).to.be.undefined }) it("should not throw an exception if no auth token exists", async () => { - await CloudApi.clearAuthToken(log, globalConfigStore, domain) - await CloudApi.clearAuthToken(log, globalConfigStore, domain) + await clearAuthToken(log, globalConfigStore, domain) + await clearAuthToken(log, globalConfigStore, domain) }) }) }) diff --git a/core/test/unit/src/commands/login.ts b/core/test/unit/src/commands/login.ts index 0887194a97..f287fb047b 100644 --- a/core/test/unit/src/commands/login.ts +++ b/core/test/unit/src/commands/login.ts @@ -10,11 +10,11 @@ import { expect } from "chai" import * as td from "testdouble" import type { TempDirectory } from "../../../helpers.js" import { expectError, getDataDir, makeTempDir, makeTestGarden, withDefaultGlobalOpts } from "../../../helpers.js" -import { AuthRedirectServer } from "../../../../src/cloud/auth.js" +import { AuthRedirectServer, getStoredAuthToken, saveAuthToken } from "../../../../src/cloud/auth.js" import { LoginCommand } from "../../../../src/commands/login.js" import { randomString } from "../../../../src/util/string.js" -import { CloudApi } from "../../../../src/cloud/api.js" +import { GardenCloudApi } from "../../../../src/cloud/api.js" import { LogLevel } from "../../../../src/logger/logger.js" import { DEFAULT_GARDEN_CLOUD_DOMAIN, gardenEnv } from "../../../../src/constants.js" import { getLogMessages } from "../../../../src/util/testing.js" @@ -73,7 +73,7 @@ describe("LoginCommand", () => { await command.action(loginCommandParams({ garden })) - const savedToken = await CloudApi.getStoredAuthToken(garden.log, garden.globalConfigStore, garden.cloudDomain!) + const savedToken = await getStoredAuthToken(garden.log, garden.globalConfigStore, garden.cloudDomain!) expect(savedToken).to.exist expect(savedToken!.token).to.eql(testToken.token) expect(savedToken!.refreshToken).to.eql(testToken.refreshToken) @@ -99,7 +99,7 @@ describe("LoginCommand", () => { await command.action(loginCommandParams({ garden })) - const savedToken = await CloudApi.getStoredAuthToken(garden.log, garden.globalConfigStore, garden.cloudDomain!) + const savedToken = await getStoredAuthToken(garden.log, garden.globalConfigStore, garden.cloudDomain!) expect(savedToken).to.exist expect(savedToken!.token).to.eql(testToken.token) expect(savedToken!.refreshToken).to.eql(testToken.refreshToken) @@ -120,13 +120,13 @@ describe("LoginCommand", () => { globalConfigStore, }) - await CloudApi.saveAuthToken(garden.log, garden.globalConfigStore, testToken, garden.cloudDomain!) - td.replace(CloudApi.prototype, "checkClientAuthToken", async () => true) - td.replace(CloudApi.prototype, "startInterval", async () => {}) + await saveAuthToken(garden.log, garden.globalConfigStore, testToken, garden.cloudDomain!) + td.replace(GardenCloudApi.prototype, "checkClientAuthToken", async () => true) + td.replace(GardenCloudApi.prototype, "startInterval", async () => {}) await command.action(loginCommandParams({ garden })) - const savedToken = await CloudApi.getStoredAuthToken(garden.log, garden.globalConfigStore, garden.cloudDomain!) + const savedToken = await getStoredAuthToken(garden.log, garden.globalConfigStore, garden.cloudDomain!) expect(savedToken).to.exist const logOutput = getLogMessages(garden.log, (entry) => entry.level === LogLevel.info).join("\n") @@ -160,7 +160,7 @@ describe("LoginCommand", () => { }, 500) await command.action(loginCommandParams({ garden })) - const savedToken = await CloudApi.getStoredAuthToken(garden.log, garden.globalConfigStore, cloudDomain) + const savedToken = await getStoredAuthToken(garden.log, garden.globalConfigStore, cloudDomain) expect(savedToken).to.exist expect(savedToken!.token).to.eql(testToken.token) expect(savedToken!.refreshToken).to.eql(testToken.refreshToken) @@ -184,11 +184,7 @@ describe("LoginCommand", () => { await command.action(loginCommandParams({ garden })) - const savedToken = await CloudApi.getStoredAuthToken( - garden.log, - garden.globalConfigStore, - DEFAULT_GARDEN_CLOUD_DOMAIN - ) + const savedToken = await getStoredAuthToken(garden.log, garden.globalConfigStore, DEFAULT_GARDEN_CLOUD_DOMAIN) expect(savedToken).to.exist expect(savedToken!.token).to.eql(testToken.token) @@ -210,13 +206,13 @@ describe("LoginCommand", () => { globalConfigStore, }) - await CloudApi.saveAuthToken(garden.log, garden.globalConfigStore, testToken, garden.cloudDomain!) - td.replace(CloudApi.prototype, "checkClientAuthToken", async () => false) - td.replace(CloudApi.prototype, "refreshToken", async () => { + await saveAuthToken(garden.log, garden.globalConfigStore, testToken, garden.cloudDomain!) + td.replace(GardenCloudApi.prototype, "checkClientAuthToken", async () => false) + td.replace(GardenCloudApi.prototype, "refreshToken", async () => { throw new Error("bummer") }) - const savedToken = await CloudApi.getStoredAuthToken(garden.log, garden.globalConfigStore, garden.cloudDomain!) + const savedToken = await getStoredAuthToken(garden.log, garden.globalConfigStore, garden.cloudDomain!) expect(savedToken).to.exist expect(savedToken!.token).to.eql(testToken.token) expect(savedToken!.refreshToken).to.eql(testToken.refreshToken) @@ -282,7 +278,7 @@ describe("LoginCommand", () => { await command.action(loginCommandParams({ garden, opts: { "disable-project-check": true } })) - const savedToken = await CloudApi.getStoredAuthToken(garden.log, garden.globalConfigStore, cloudDomain) + const savedToken = await getStoredAuthToken(garden.log, garden.globalConfigStore, cloudDomain) // reset the cloud domain gardenEnv.GARDEN_CLOUD_DOMAIN = savedDomain @@ -305,7 +301,7 @@ describe("LoginCommand", () => { globalConfigStore, }) - td.replace(CloudApi.prototype, "checkClientAuthToken", async () => true) + td.replace(GardenCloudApi.prototype, "checkClientAuthToken", async () => true) await command.action(loginCommandParams({ garden })) @@ -322,7 +318,7 @@ describe("LoginCommand", () => { globalConfigStore, }) - td.replace(CloudApi.prototype, "checkClientAuthToken", async () => false) + td.replace(GardenCloudApi.prototype, "checkClientAuthToken", async () => false) await expectError(async () => await command.action(loginCommandParams({ garden })), { contains: `The provided access token is expired or has been revoked for ${garden.cloudDomain}, please create a new one from the Garden Enterprise UI`, @@ -360,11 +356,7 @@ describe("LoginCommand", () => { await command.action(loginCommandParams({ garden })) - const savedToken = await CloudApi.getStoredAuthToken( - garden.log, - garden.globalConfigStore, - gardenEnv.GARDEN_CLOUD_DOMAIN - ) + const savedToken = await getStoredAuthToken(garden.log, garden.globalConfigStore, gardenEnv.GARDEN_CLOUD_DOMAIN) expect(savedToken).to.exist expect(savedToken!.token).to.eql(testToken.token) expect(savedToken!.refreshToken).to.eql(testToken.refreshToken) @@ -390,11 +382,7 @@ describe("LoginCommand", () => { await command.action(loginCommandParams({ garden })) - const savedToken = await CloudApi.getStoredAuthToken( - garden.log, - garden.globalConfigStore, - gardenEnv.GARDEN_CLOUD_DOMAIN - ) + const savedToken = await getStoredAuthToken(garden.log, garden.globalConfigStore, gardenEnv.GARDEN_CLOUD_DOMAIN) expect(savedToken).to.exist expect(savedToken!.token).to.eql(testToken.token) expect(savedToken!.refreshToken).to.eql(testToken.refreshToken) diff --git a/core/test/unit/src/commands/logout.ts b/core/test/unit/src/commands/logout.ts index 408b37706e..fd0aef4314 100644 --- a/core/test/unit/src/commands/logout.ts +++ b/core/test/unit/src/commands/logout.ts @@ -11,7 +11,7 @@ import * as td from "testdouble" import type { TempDirectory } from "../../../helpers.js" import { getDataDir, makeTempDir, makeTestGarden, withDefaultGlobalOpts } from "../../../helpers.js" import { randomString } from "../../../../src/util/string.js" -import { CloudApi } from "../../../../src/cloud/api.js" +import { GardenCloudApi } from "../../../../src/cloud/api.js" import { LogLevel } from "../../../../src/logger/logger.js" import { LogOutCommand } from "../../../../src/commands/logout.js" import { expectError, getLogMessages } from "../../../../src/util/testing.js" @@ -20,6 +20,7 @@ import { DEFAULT_GARDEN_CLOUD_DOMAIN, gardenEnv } from "../../../../src/constant import { GlobalConfigStore } from "../../../../src/config-store/global.js" import type { Garden } from "../../../../src/index.js" import { makeDummyGarden } from "../../../../src/garden.js" +import { getStoredAuthToken, saveAuthToken } from "../../../../src/cloud/auth.js" // eslint-disable-next-line @typescript-eslint/no-explicit-any function logoutCommandParams({ garden, opts = { "disable-project-check": false } }: { garden: Garden; opts?: any }) { @@ -62,24 +63,20 @@ describe("LogoutCommand", () => { globalConfigStore, }) - await CloudApi.saveAuthToken(garden.log, garden.globalConfigStore, testToken, garden.cloudDomain!) - td.replace(CloudApi.prototype, "checkClientAuthToken", async () => true) - td.replace(CloudApi.prototype, "startInterval", async () => {}) - td.replace(CloudApi.prototype, "post", async () => {}) + await saveAuthToken(garden.log, garden.globalConfigStore, testToken, garden.cloudDomain!) + td.replace(GardenCloudApi.prototype, "checkClientAuthToken", async () => true) + td.replace(GardenCloudApi.prototype, "startInterval", async () => {}) + td.replace(GardenCloudApi.prototype, "post", async () => {}) // Double check token actually exists - const savedToken = await CloudApi.getStoredAuthToken(garden.log, garden.globalConfigStore, garden.cloudDomain!) + const savedToken = await getStoredAuthToken(garden.log, garden.globalConfigStore, garden.cloudDomain!) expect(savedToken).to.exist expect(savedToken!.token).to.eql(testToken.token) expect(savedToken!.refreshToken).to.eql(testToken.refreshToken) await command.action(logoutCommandParams({ garden })) - const tokenAfterLogout = await CloudApi.getStoredAuthToken( - garden.log, - garden.globalConfigStore, - garden.cloudDomain! - ) + const tokenAfterLogout = await getStoredAuthToken(garden.log, garden.globalConfigStore, garden.cloudDomain!) const logOutput = getLogMessages(garden.log, (entry) => entry.level === LogLevel.info).join("\n") expect(tokenAfterLogout).to.not.exist @@ -100,24 +97,20 @@ describe("LogoutCommand", () => { commandInfo: { name: "foo", args: {}, opts: {} }, }) - await CloudApi.saveAuthToken(garden.log, garden.globalConfigStore, testToken, garden.cloudDomain!) - td.replace(CloudApi.prototype, "checkClientAuthToken", async () => true) - td.replace(CloudApi.prototype, "startInterval", async () => {}) - td.replace(CloudApi.prototype, "post", async () => {}) + await saveAuthToken(garden.log, garden.globalConfigStore, testToken, garden.cloudDomain!) + td.replace(GardenCloudApi.prototype, "checkClientAuthToken", async () => true) + td.replace(GardenCloudApi.prototype, "startInterval", async () => {}) + td.replace(GardenCloudApi.prototype, "post", async () => {}) // Double check token actually exists - const savedToken = await CloudApi.getStoredAuthToken(garden.log, garden.globalConfigStore, garden.cloudDomain!) + const savedToken = await getStoredAuthToken(garden.log, garden.globalConfigStore, garden.cloudDomain!) expect(savedToken).to.exist expect(savedToken!.token).to.eql(testToken.token) expect(savedToken!.refreshToken).to.eql(testToken.refreshToken) await command.action(logoutCommandParams({ garden })) - const tokenAfterLogout = await CloudApi.getStoredAuthToken( - garden.log, - garden.globalConfigStore, - garden.cloudDomain! - ) + const tokenAfterLogout = await getStoredAuthToken(garden.log, garden.globalConfigStore, garden.cloudDomain!) const logOutput = getLogMessages(garden.log, (entry) => entry.level === LogLevel.info).join("\n") expect(tokenAfterLogout).to.not.exist @@ -153,25 +146,21 @@ describe("LogoutCommand", () => { globalConfigStore, }) - await CloudApi.saveAuthToken(garden.log, garden.globalConfigStore, testToken, garden.cloudDomain!) + await saveAuthToken(garden.log, garden.globalConfigStore, testToken, garden.cloudDomain!) // Throw when initializing Enterprise API - td.replace(CloudApi.prototype, "factory", async () => { + td.replace(GardenCloudApi.prototype, "factory", async () => { throw new Error("Not tonight") }) // Double check token actually exists - const savedToken = await CloudApi.getStoredAuthToken(garden.log, garden.globalConfigStore, garden.cloudDomain!) + const savedToken = await getStoredAuthToken(garden.log, garden.globalConfigStore, garden.cloudDomain!) expect(savedToken).to.exist expect(savedToken!.token).to.eql(testToken.token) expect(savedToken!.refreshToken).to.eql(testToken.refreshToken) await command.action(logoutCommandParams({ garden })) - const tokenAfterLogout = await CloudApi.getStoredAuthToken( - garden.log, - garden.globalConfigStore, - garden.cloudDomain! - ) + const tokenAfterLogout = await getStoredAuthToken(garden.log, garden.globalConfigStore, garden.cloudDomain!) const logOutput = getLogMessages(garden.log, (entry) => entry.level === LogLevel.info).join("\n") expect(tokenAfterLogout).to.not.exist @@ -193,25 +182,21 @@ describe("LogoutCommand", () => { globalConfigStore, }) - await CloudApi.saveAuthToken(garden.log, garden.globalConfigStore, testToken, garden.cloudDomain!) + await saveAuthToken(garden.log, garden.globalConfigStore, testToken, garden.cloudDomain!) // Throw when using Enterprise API to call logout endpoint - td.replace(CloudApi.prototype, "post", async () => { + td.replace(GardenCloudApi.prototype, "post", async () => { throw new Error("Not tonight") }) // Double check token actually exists - const savedToken = await CloudApi.getStoredAuthToken(garden.log, garden.globalConfigStore, garden.cloudDomain!) + const savedToken = await getStoredAuthToken(garden.log, garden.globalConfigStore, garden.cloudDomain!) expect(savedToken).to.exist expect(savedToken!.token).to.eql(testToken.token) expect(savedToken!.refreshToken).to.eql(testToken.refreshToken) await command.action(logoutCommandParams({ garden })) - const tokenAfterLogout = await CloudApi.getStoredAuthToken( - garden.log, - garden.globalConfigStore, - garden.cloudDomain! - ) + const tokenAfterLogout = await getStoredAuthToken(garden.log, garden.globalConfigStore, garden.cloudDomain!) const logOutput = getLogMessages(garden.log, (entry) => entry.level === LogLevel.info).join("\n") expect(tokenAfterLogout).to.not.exist @@ -251,13 +236,13 @@ describe("LogoutCommand", () => { globalConfigStore, }) - await CloudApi.saveAuthToken(garden.log, garden.globalConfigStore, testToken, garden.cloudDomain!) - td.replace(CloudApi.prototype, "checkClientAuthToken", async () => true) - td.replace(CloudApi.prototype, "startInterval", async () => {}) - td.replace(CloudApi.prototype, "post", async () => {}) + await saveAuthToken(garden.log, garden.globalConfigStore, testToken, garden.cloudDomain!) + td.replace(GardenCloudApi.prototype, "checkClientAuthToken", async () => true) + td.replace(GardenCloudApi.prototype, "startInterval", async () => {}) + td.replace(GardenCloudApi.prototype, "post", async () => {}) // Double check token actually exists - const savedToken = await CloudApi.getStoredAuthToken(garden.log, garden.globalConfigStore, garden.cloudDomain!) + const savedToken = await getStoredAuthToken(garden.log, garden.globalConfigStore, garden.cloudDomain!) expect(savedToken).to.exist expect(savedToken!.token).to.eql(testToken.token) expect(savedToken!.refreshToken).to.eql(testToken.refreshToken) @@ -274,11 +259,7 @@ describe("LogoutCommand", () => { gardenEnv.GARDEN_CLOUD_DOMAIN = savedDomain - const tokenAfterLogout = await CloudApi.getStoredAuthToken( - garden.log, - garden.globalConfigStore, - garden.cloudDomain! - ) + const tokenAfterLogout = await getStoredAuthToken(garden.log, garden.globalConfigStore, garden.cloudDomain!) const logOutput = getLogMessages(garden.log, (entry) => entry.level === LogLevel.info).join("\n") expect(tokenAfterLogout).to.not.exist diff --git a/core/test/unit/src/garden.ts b/core/test/unit/src/garden.ts index 0a0241729d..572574aefb 100644 --- a/core/test/unit/src/garden.ts +++ b/core/test/unit/src/garden.ts @@ -69,7 +69,7 @@ import { TreeCache } from "../../../src/cache.js" import { omitUndefined } from "../../../src/util/objects.js" import { add } from "date-fns" import stripAnsi from "strip-ansi" -import { CloudApi } from "../../../src/cloud/api.js" +import { GardenCloudApi } from "../../../src/cloud/api.js" import { GlobalConfigStore } from "../../../src/config-store/global.js" import { LogLevel, getRootLogger } from "../../../src/logger/logger.js" import { uuidv4 } from "../../../src/util/random.js" @@ -744,7 +744,7 @@ describe("Garden", () => { refreshToken: "fake-refresh-token", validity: add(new Date(), { seconds: validityMs / 1000 }), }) - return CloudApi.factory({ log, cloudDomain: domain, globalConfigStore }) + return GardenCloudApi.factory({ log, cloudDomain: domain, globalConfigStore }) } before(async () => {