Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: improve installer job feedback #1755

Merged
merged 17 commits into from
Oct 22, 2024
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion charts/otomi-pipelines/templates/tekton-otomi-task.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -82,4 +82,4 @@ spec:
set -e
# Prevent the detected dubious ownership in repository error
git config --global --add safe.directory '*'
binzx/otomi apply
binzx/otomi apply --tekton
62 changes: 31 additions & 31 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

29 changes: 21 additions & 8 deletions src/cmd/apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,13 @@ import { ProcessOutputTrimmed } from 'src/common/zx-enhance'
import { Argv, CommandModule } from 'yargs'
import { $, nothrow } from 'zx'
import { applyAsApps } from './apply-as-apps'
import { cloneOtomiChartsInGitea, commit, printWelcomeMessage, retryCheckingForPipelinerun } from './commit'
import {
cloneOtomiChartsInGitea,
commit,
printWelcomeMessage,
retryCheckingForPipelineRun,
retryIsOAuth2ProxyRunning,
} from './commit'
import { upgrade } from './upgrade'

const cmdName = getFilename(__filename)
Expand All @@ -35,9 +41,9 @@ const setup = (): void => {
const applyAll = async () => {
const d = terminal(`cmd:${cmdName}:applyAll`)
const prevState = await getDeploymentState()
const intitalInstall = isEmpty(prevState.version)
const initialInstall = isEmpty(prevState.version)
const argv: HelmArguments = getParsedArgs()
const hfArgs = intitalInstall
const hfArgs = initialInstall
? ['sync', '--concurrency=1', '--sync-args', '--disable-openapi-validation --qps=20']
: ['apply', '--sync-args', '--qps=20']

Expand Down Expand Up @@ -83,7 +89,7 @@ const applyAll = async () => {
await prepareDomainSuffix()

let labelOpts = ['']
if (intitalInstall) {
if (initialInstall && !argv.tekton) {
// When Otomi is installed for the very first time and ArgoCD is not yet there.
// Only install the core apps
labelOpts = ['app=core']
Expand All @@ -109,7 +115,7 @@ const applyAll = async () => {
await upgrade({ when: 'post' })
if (!(env.isDev && env.DISABLE_SYNC)) {
await commit()
if (intitalInstall) {
if (initialInstall && !argv.tekton) {
j-zimnowoda marked this conversation as resolved.
Show resolved Hide resolved
await hf(
{
// 'fileOpts' limits the hf scope and avoids parse errors (we only have basic values in this statege):
Expand All @@ -120,7 +126,8 @@ const applyAll = async () => {
{ streams: { stdout: d.stream.log, stderr: d.stream.error } },
)
await cloneOtomiChartsInGitea()
await retryCheckingForPipelinerun()
await retryCheckingForPipelineRun()
await retryIsOAuth2ProxyRunning()
j-zimnowoda marked this conversation as resolved.
Show resolved Hide resolved
await printWelcomeMessage()
}
}
Expand Down Expand Up @@ -167,8 +174,14 @@ const apply = async (): Promise<void> => {
export const module: CommandModule = {
command: cmdName,
describe: 'Apply all, or supplied, k8s resources',
builder: (parser: Argv): Argv => helmOptions(parser),

builder: (parser: Argv): Argv =>
helmOptions(parser).option({
tekton: {
type: 'boolean',
description: 'Apply flag when run in tekton pipeline',
default: false,
},
}),
handler: async (argv: HelmArguments): Promise<void> => {
setParsedArgs(argv)
setup()
Expand Down
90 changes: 90 additions & 0 deletions src/cmd/commit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/* eslint-disable @typescript-eslint/unbound-method */
import { AppsV1Api } from '@kubernetes/client-node'
import { terminal } from '../common/debug'
import { isOAuth2ProxyRunning } from './commit'

jest.mock('../common/debug')
jest.mock('@kubernetes/client-node')

describe('isOAuth2ProxyRunning', () => {
const mockAppsV1Api = new AppsV1Api() as jest.Mocked<AppsV1Api>
const mockTerminal = terminal as jest.MockedFunction<typeof terminal>

const mockTerminalInfo = jest.fn()

beforeEach(() => {
mockTerminal.mockReturnValue({
info: mockTerminalInfo,
} as any)
})

afterEach(() => {
jest.clearAllMocks()
})

it('should throw an error if the OAuth2 Proxy deployment is not found', async () => {
mockAppsV1Api.readNamespacedDeployment.mockResolvedValue({ body: null } as any)

ferruhcihan marked this conversation as resolved.
Show resolved Hide resolved
await expect(isOAuth2ProxyRunning(mockAppsV1Api)).rejects.toThrow('OAuth2 Proxy deployment not found, waiting...')
expect(mockAppsV1Api.readNamespacedDeployment).toHaveBeenCalledWith('oauth2-proxy', 'istio-system')
})

it('should throw an error if the OAuth2 Proxy deployment has no status', async () => {
mockAppsV1Api.readNamespacedDeployment.mockResolvedValue({ body: { status: null } } as any)

await expect(isOAuth2ProxyRunning(mockAppsV1Api)).rejects.toThrow('OAuth2 Proxy has no status, waiting...')
expect(mockAppsV1Api.readNamespacedDeployment).toHaveBeenCalledWith('oauth2-proxy', 'istio-system')
})

it('should throw an error if the OAuth2 Proxy deployment has availableReplicas 0', async () => {
// @ts-ignore
mockAppsV1Api.readNamespacedDeployment.mockResolvedValue({ body: { status: { availableReplicas: 0 } } })

await expect(isOAuth2ProxyRunning(mockAppsV1Api)).rejects.toThrow(
'OAuth2 Proxy has no available replicas, waiting...',
)
expect(mockAppsV1Api.readNamespacedDeployment).toHaveBeenCalledWith('oauth2-proxy', 'istio-system')
})

it('should throw an error if the OAuth2 Proxy deployment has no availableReplicas', async () => {
mockAppsV1Api.readNamespacedDeployment.mockResolvedValue({ body: { status: { replicas: 1 } } } as any)

await expect(isOAuth2ProxyRunning(mockAppsV1Api)).rejects.toThrow(
'OAuth2 Proxy has no available replicas, waiting...',
)
expect(mockAppsV1Api.readNamespacedDeployment).toHaveBeenCalledWith('oauth2-proxy', 'istio-system')
})

it('should throw an error if the OAuth2 Proxy deployment has only unavailableReplicas', async () => {
mockAppsV1Api.readNamespacedDeployment.mockResolvedValue({
body: { status: { replicas: 1, unavailableReplicas: 1 } },
} as any)

await expect(isOAuth2ProxyRunning(mockAppsV1Api)).rejects.toThrow(
'OAuth2 Proxy has no available replicas, waiting...',
)
expect(mockAppsV1Api.readNamespacedDeployment).toHaveBeenCalledWith('oauth2-proxy', 'istio-system')
})

it('should log success if OAuth2 Proxy deployment has one availableReplicas and one unavailableReplicas', async () => {
mockAppsV1Api.readNamespacedDeployment.mockResolvedValue({
body: { status: { replicas: 2, unavailableReplicas: 1, availableReplicas: 1 } },
} as any)

await expect(isOAuth2ProxyRunning(mockAppsV1Api)).resolves.toBeUndefined()

expect(mockTerminalInfo).toHaveBeenCalledWith('OAuth2proxy is running, continuing...')
expect(mockAppsV1Api.readNamespacedDeployment).toHaveBeenCalledWith('oauth2-proxy', 'istio-system')
})

it('should log success if the OAuth2 Proxy deployment is running', async () => {
mockAppsV1Api.readNamespacedDeployment.mockResolvedValue({
body: { status: { replicas: 1, availableReplicas: 1 } },
} as any)

await expect(isOAuth2ProxyRunning(mockAppsV1Api)).resolves.toBeUndefined()

expect(mockTerminalInfo).toHaveBeenCalledWith('OAuth2proxy is running, continuing...')
expect(mockAppsV1Api.readNamespacedDeployment).toHaveBeenCalledWith('oauth2-proxy', 'istio-system')
})
})
67 changes: 56 additions & 11 deletions src/cmd/commit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@ import { encrypt } from 'src/common/crypt'
import { terminal } from 'src/common/debug'
import { env, isCi } from 'src/common/envalid'
import { hfValues } from 'src/common/hf'
import { waitTillGitRepoAvailable } from 'src/common/k8s'
import { createGenericSecret, waitTillGitRepoAvailable } from 'src/common/k8s'
import { getFilename } from 'src/common/utils'
import { getRepo } from 'src/common/values'
import { HelmArguments, getParsedArgs, setParsedArgs } from 'src/common/yargs'
import { Argv } from 'yargs'
import { $, cd } from 'zx'
import { Arguments as DroneArgs } from './gen-drone'
import { validateValues } from './validate-values'
import { CustomObjectsApi, KubeConfig } from '@kubernetes/client-node'
import { AppsV1Api, CoreV1Api, CustomObjectsApi, KubeConfig } from '@kubernetes/client-node'
import retry from 'async-retry'

const cmdName = getFilename(__filename)
Expand Down Expand Up @@ -106,8 +106,8 @@ export const cloneOtomiChartsInGitea = async (): Promise<void> => {
d.info('Cloned apl-charts in Gitea')
}

export async function retryCheckingForPipelinerun() {
const d = terminal(`cmd:${cmdName}:apply`)
export async function retryCheckingForPipelineRun() {
const d = terminal(`cmd:${cmdName}:pipelineRun`)
await retry(
async () => {
await checkIfPipelineRunExists()
Expand All @@ -119,8 +119,41 @@ export async function retryCheckingForPipelinerun() {
})
}

export async function retryIsOAuth2ProxyRunning() {
const d = terminal(`cmd:${cmdName}:isOAuth2ProxyRunning`)
const kc = new KubeConfig()
kc.loadFromDefault()
const appsV1Api = kc.makeApiClient(AppsV1Api)
await retry(
async () => {
await isOAuth2ProxyRunning(appsV1Api)
},
{ retries: env.RETRIES, randomize: env.RANDOM, minTimeout: env.MIN_TIMEOUT, factor: env.FACTOR },
).catch((e) => {
d.error('Error checking if OAuth2Proxy is ready:', e)
throw e
})
}

export async function isOAuth2ProxyRunning(k8s: AppsV1Api): Promise<void> {
const d = terminal(`cmd:${cmdName}:isOAuth2ProxyRunning`)
d.info('Checking if OAuth2Proxy is running, waiting...')
const { body: oauth2ProxyDeployment } = await k8s.readNamespacedDeployment('oauth2-proxy', 'istio-system')
if (!oauth2ProxyDeployment) {
throw new Error('OAuth2 Proxy deployment not found, waiting...')
}
const oauth2ProxyStatus = oauth2ProxyDeployment.status
if (!oauth2ProxyStatus) {
throw new Error('OAuth2 Proxy has no status, waiting...')
}
if (!oauth2ProxyStatus.availableReplicas || oauth2ProxyStatus.availableReplicas < 1) {
throw new Error('OAuth2 Proxy has no available replicas, waiting...')
}
d.info('OAuth2proxy is running, continuing...')
}

export async function checkIfPipelineRunExists(): Promise<void> {
const d = terminal(`cmd:${cmdName}:pipelinerun`)
const d = terminal(`cmd:${cmdName}:pipelineRun`)
const kc = new KubeConfig()
kc.loadFromDefault()
const customObjectsApi = kc.makeApiClient(CustomObjectsApi)
Expand All @@ -142,19 +175,31 @@ export async function checkIfPipelineRunExists(): Promise<void> {
d.info(`There is a Tekton PipelineRuns continuing...`)
}

async function createRootCredentialsSecret(credentials: { adminUsername: string; adminPassword: string }) {
const secretData = {
username: credentials.adminUsername,
password: credentials.adminPassword,
}
const kc = new KubeConfig()
kc.loadFromDefault()
const coreV1Api = kc.makeApiClient(CoreV1Api)
await createGenericSecret(coreV1Api, 'root-credentials', 'default', secretData)
}

export const printWelcomeMessage = async (): Promise<void> => {
const d = terminal(`cmd:${cmdName}:commit`)
const values = (await hfValues()) as Record<string, any>
const credentials = values.apps.keycloak
await createRootCredentialsSecret({
adminUsername: credentials.adminUsername,
adminPassword: credentials.adminPassword,
})
const message = `
########################################################################################################################################
#
# Core apps installation complete! ArgoCD will now deploy the remaining applications.
# To monitor the progress, run: kubectl get applications -A
# Once ArgoCD finishes, you can start using APL. Visit: https://console.${values.cluster.domainSuffix}
# Sign in to the web console with the following credentials:
# - Username: "${credentials.adminUsername}"
# - Password: "${credentials.adminPassword}"
# Visit the console at: https://console.${values.cluster.domainSuffix}
# Perform: kubectl get secret root-credentials -n default -o yaml
# To obtain access credentials in base64 encoded format
#
########################################################################################################################################`
d.info(message)
Expand Down
4 changes: 2 additions & 2 deletions src/common/envalid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,10 @@ export const cliEnvSpec = {
TRACE: bool({ default: false }),
VERBOSITY: num({ desc: 'The verbosity level', default: 1 }),
VALUES_INPUT: str({ desc: 'The chart values.yaml file', default: undefined }),
RETRIES: num({ desc: 'The maximum amount of times to retry the operation by the reconciler', default: 6 }),
RETRIES: num({ desc: 'The maximum amount of times to retry the operation by the reconciler', default: 10 }),
RANDOM: bool({ desc: 'Randomizes the timeouts by multiplying with a factor between 1 to 2', default: false }),
MIN_TIMEOUT: num({ desc: 'The number of milliseconds before starting the first retry', default: 10000 }),
FACTOR: num({ desc: 'The factor to multiply the timeout with', default: 1 }),
FACTOR: num({ desc: 'The factor to multiply the timeout with', default: 1.5 }),
}

export function cleanEnv<T>(
Expand Down
Loading