Skip to content

Commit

Permalink
chore: cloud api factory throws specific errors
Browse files Browse the repository at this point in the history
  • Loading branch information
mkhq committed Dec 13, 2023
1 parent b73a5b6 commit c62e628
Show file tree
Hide file tree
Showing 5 changed files with 119 additions and 84 deletions.
67 changes: 38 additions & 29 deletions core/src/cloud/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,21 @@ import { styles } from "../logger/styles.js"
const gardenClientName = "garden-core"
const gardenClientVersion = getPackageVersion()

// Thrown when trying to create a project with a name that already exists
export class CloudApiDuplicateProjectsError extends CloudApiError {}

// A user token can be refreshed, thrown when the refresh fails
export class CloudApiTokenRefreshError extends CloudApiError {}

// The access token passed via GARDEN_AUTH_TOKEN was not valid
export class CloudApiAccessTokenInvalidError extends CloudApiError {}

// Thrown when the user is not logged in with a cloud connected project
export class CloudApiLoginRequiredError extends CloudApiError {}

// Thrown there is no auth or access token
export class CloudApiNoTokenError extends CloudApiError {}

function extractErrorMessageBodyFromGotError(error: any): error is GotHttpError {
return error?.response?.body?.message
}
Expand Down Expand Up @@ -244,7 +255,7 @@ export class CloudApi {
cloudFactoryLog.debug(
`No auth token found, proceeding without access to ${distroName}. Command results for this command run will not be available in ${distroName}.`
)
return
throw new CloudApiNoTokenError({ message: `No auth token available for ${distroName} at ${cloudDomain}` })
}

// Try to auth towards cloud
Expand All @@ -254,29 +265,26 @@ export class CloudApi {

cloudFactoryLog.debug("Authorizing...")

if (gardenEnv.GARDEN_AUTH_TOKEN) {
// Throw if using an invalid "CI" access token
if (!tokenIsValid) {
throw new CloudApiError({
message: deline`
if (gardenEnv.GARDEN_AUTH_TOKEN && !tokenIsValid) {
throw new CloudApiAccessTokenInvalidError({
message: deline`
The provided access token is expired or has been revoked, please create a new
one from the ${distroName} UI.`,
})
}
} else {
// Refresh the token if it's invalid.
if (!tokenIsValid) {
cloudFactoryLog.debug({ msg: `Current auth token is invalid, refreshing` })
})
}

// We can assert the token exists since we're not using GARDEN_AUTH_TOKEN
await api.refreshToken(token!)
}
// Try to refresh the token if it's invalid.
if (!tokenIsValid) {
cloudFactoryLog.debug({ msg: `Current auth token is invalid, refreshing` })

// Start refresh interval if using JWT
cloudFactoryLog.debug({ msg: `Starting refresh interval.` })
api.startInterval()
// We can assert the token exists since we're not using GARDEN_AUTH_TOKEN
await api.refreshToken(token!)
}

// Start refresh interval if using JWT
cloudFactoryLog.debug({ msg: `Starting refresh interval.` })
api.startInterval()

return api
} catch (err) {
if (err instanceof CloudApiError) {
Expand All @@ -287,15 +295,7 @@ export class CloudApi {
You are running this in a project with a Garden Cloud ID and logging in is required.
Please log in via the ${styles.command("garden login")} command.`

throw new CloudApiError({ message })
} else {
cloudFactoryLog.warn(
`Warning: You are not logged in to Garden Cloud. Please log in via the ${styles.command(
"garden login"
)} command.`
)

return
throw new CloudApiLoginRequiredError({ message })
}
}

Expand Down Expand Up @@ -517,8 +517,17 @@ export class CloudApi {
throw err
}

this.log.debug({ msg: `Failed to refresh the token.` })
throw new CloudApiTokenRefreshError({
this.log.debug({ msg: `Failed to refresh the auth token, response status code: ${err.response.statusCode}` })

// The token was invalid and could not be refreshed
if (err.response.statusCode === 401) {
throw new CloudApiTokenRefreshError({
message: `The auth token could not be refreshed for ${getCloudDistributionName(this.domain)}`,
})
}

// Unhandled cloud api error
throw new CloudApiError({
message: `An error occurred while verifying client auth token with ${getCloudDistributionName(this.domain)}: ${
err.message
}. Response status code: ${err.response.statusCode}`,
Expand Down
25 changes: 13 additions & 12 deletions core/src/commands/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ 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 { CloudApi, CloudApiNoTokenError, CloudApiTokenRefreshError, 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"
Expand Down Expand Up @@ -86,8 +86,6 @@ export class LoginCommand extends Command<{}, Opts> {
// should use the default domain or not. The token lifecycle ends on logout.
const cloudDomain: string = getGardenCloudDomain(projectConfig?.domain)

const distroName = getCloudDistributionName(cloudDomain)

try {
const cloudApi = await CloudApi.factory({
log,
Expand All @@ -98,24 +96,27 @@ export class LoginCommand extends Command<{}, Opts> {
requireLogin: undefined,
})

if (cloudApi) {
log.success({ msg: `You're already logged in to ${cloudDomain}.` })
cloudApi.close()
return {}
}
log.success({ msg: `You're already logged in to ${cloudDomain}.` })
cloudApi.close()
return {}
} catch (err) {
if (!(err instanceof CloudApiError)) {
throw err
}
if (err.responseStatusCode === 401) {

if (err instanceof CloudApiTokenRefreshError) {
const msg = dedent`
Looks like your session token is invalid. If you were previously logged into a different instance
of ${distroName}, log out first before logging in.
Your login token for ${cloudDomain} has expired and could not be refreshed.
Try to log out first before logging in.
`
log.warn(msg)
log.info("")
}
throw err

// This is expected if the user has not logged in
if (!(err instanceof CloudApiNoTokenError)) {
throw err
}
}

log.info({ msg: `Logging in to ${cloudDomain}...` })
Expand Down
1 change: 0 additions & 1 deletion core/src/config/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,6 @@ export const projectSchema = createSchema({
// TODO: Refer to enterprise documentation for more details.
requireLogin: joi
.boolean()
.default(false)
.meta({ internal: true })
.description("Whether the project requires login to Garden Cloud."),

Expand Down
101 changes: 64 additions & 37 deletions core/test/unit/src/cloud/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,18 +97,23 @@ describe("CloudApi", () => {

scope.get("/api/token/verify").reply(404)

const api = await CloudApi.factory({
log,
globalConfigStore,
cloudDomain,
projectId: undefined,
requireLogin: undefined,
})
await expectError(
async () =>
await CloudApi.factory({
log,
globalConfigStore,
cloudDomain,
projectId: undefined,
requireLogin: undefined,
}),
{
type: "cloud-api",
contains: "No auth token available for",
}
)

// we don't expect a request to verify the token
expect(scope.isDone()).to.be.false

expect(api).to.be.undefined
})

it("should return a CloudApi instance if there is a valid token", async () => {
Expand Down Expand Up @@ -136,17 +141,22 @@ describe("CloudApi", () => {

scope.get("/api/token/verify").reply(200, {})

const api = await CloudApi.factory({
log,
globalConfigStore,
cloudDomain,
projectId: "test",
requireLogin: false,
})
await expectError(
async () =>
await CloudApi.factory({
log,
globalConfigStore,
cloudDomain,
projectId: "test",
requireLogin: false,
}),
{
type: "cloud-api",
contains: "No auth token available for",
}
)

expect(scope.isDone()).to.be.false

expect(api).to.be.undefined
})

it("should not return a CloudApi instance with an invalid token when require login is false", async () => {
Expand All @@ -157,17 +167,22 @@ describe("CloudApi", () => {
scope.get("/api/token/verify").reply(401, {})
scope.get("/api/token/refresh").reply(401, {})

const api = await CloudApi.factory({
log,
globalConfigStore,
cloudDomain,
projectId: "test",
requireLogin: false,
})
await expectError(
async () =>
await CloudApi.factory({
log,
globalConfigStore,
cloudDomain,
projectId: "test",
requireLogin: false,
}),
{
type: "cloud-api",
contains: "The auth token could not be refreshed for",
}
)

expect(scope.isDone()).to.be.true

expect(api).to.be.undefined
})

it("should throw an error when the token is invalid and require login is true", async () => {
Expand All @@ -187,7 +202,10 @@ describe("CloudApi", () => {
projectId: "test",
requireLogin: true,
}),
"cloud-api"
{
type: "cloud-api",
contains: "You are running this in a project with a Garden Cloud ID and logging in is required.",
}
)

expect(scope.isDone()).to.be.true
Expand All @@ -206,16 +224,22 @@ describe("CloudApi", () => {
gardenEnv.GARDEN_REQUIRE_LOGIN_OVERRIDE = false

try {
const api = await CloudApi.factory({
log,
globalConfigStore,
cloudDomain,
projectId: "test",
requireLogin: true,
})
await expectError(
async () =>
await CloudApi.factory({
log,
globalConfigStore,
cloudDomain,
projectId: "test",
requireLogin: true,
}),
{
type: "cloud-api",
contains: "The auth token could not be refreshed for",
}
)

expect(scope.isDone()).to.be.true
expect(api).to.be.undefined
} finally {
gardenEnv.GARDEN_REQUIRE_LOGIN_OVERRIDE = overrideEnvBackup
}
Expand Down Expand Up @@ -243,7 +267,10 @@ describe("CloudApi", () => {
projectId: "test",
requireLogin: true,
}),
"cloud-api"
{
type: "cloud-api",
contains: "You are running this in a project with a Garden Cloud ID and logging in is required.",
}
)

expect(scope.isDone()).to.be.true
Expand Down
9 changes: 4 additions & 5 deletions core/test/unit/src/commands/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,9 @@ import { AuthRedirectServer } from "../../../../src/cloud/auth.js"

import { LoginCommand } from "../../../../src/commands/login.js"
import { dedent, randomString } from "../../../../src/util/string.js"
import { CloudApi } from "../../../../src/cloud/api.js"
import { CloudApi, CloudApiTokenRefreshError } from "../../../../src/cloud/api.js"
import { LogLevel } from "../../../../src/logger/logger.js"
import { DEFAULT_GARDEN_CLOUD_DOMAIN, gardenEnv } from "../../../../src/constants.js"
import { CloudApiError } from "../../../../src/exceptions.js"
import { getLogMessages } from "../../../../src/util/testing.js"
import { GlobalConfigStore } from "../../../../src/config-store/global.js"
import { makeDummyGarden } from "../../../../src/garden.js"
Expand Down Expand Up @@ -244,7 +243,7 @@ describe("LoginCommand", () => {
await CloudApi.saveAuthToken(garden.log, garden.globalConfigStore, testToken, garden.cloudDomain!)
td.replace(CloudApi.prototype, "checkClientAuthToken", async () => false)
td.replace(CloudApi.prototype, "refreshToken", async () => {
throw new CloudApiError({ message: "bummer", responseStatusCode: 401 })
throw new CloudApiTokenRefreshError({ message: "bummer" })
})

const savedToken = await CloudApi.getStoredAuthToken(garden.log, garden.globalConfigStore, garden.cloudDomain!)
Expand All @@ -259,8 +258,8 @@ describe("LoginCommand", () => {
const logOutput = getLogMessages(garden.log, (entry) => entry.level <= LogLevel.info).join("\n")

expect(logOutput).to.include(dedent`
Looks like your session token is invalid. If you were previously logged into a different instance
of Garden Enterprise, log out first before logging in.
Your login token for https://example.invalid has expired and could not be refreshed.
Try to log out first before logging in.
`)
})

Expand Down

0 comments on commit c62e628

Please sign in to comment.