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

Open
wants to merge 16 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 12 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
2,470 changes: 1,536 additions & 934 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"fs-extra": "9.1.0",
"generate-password": "^1.7.1",
"ignore-walk": "3.0.4",
"jest-mock-extended": "^4.0.0-beta1",
"lodash": "4.17.21",
"node-fetch": "2.6.7",
"node-forge": "0.10.0",
Expand Down
25 changes: 19 additions & 6 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 Down Expand Up @@ -83,7 +89,7 @@ const applyAll = async () => {
await prepareDomainSuffix()

let labelOpts = ['']
if (intitalInstall) {
if (intitalInstall && !argv.tekton) {
ferruhcihan marked this conversation as resolved.
Show resolved Hide resolved
// 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 (intitalInstall && !argv.tekton) {
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()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just an idea, would be better to check if well_known url from keycloak is responsive?

https://keycloak.<domainSuffix>/realms/otomi/.well-known/openid-configuration

It seems that checking if Oauth2Proxy Pod is running may not be enough

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
103 changes: 103 additions & 0 deletions src/cmd/commit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { AppsV1Api } from '@kubernetes/client-node'
import { jest } from '@jest/globals'
import { terminal } from '../common/debug'
import { isOAuth2ProxyRunning } from './commit'
import { mock } from 'jest-mock-extended'

jest.mock('../common/debug')

describe('isOAuth2ProxyRunning', () => {
const mockAppsV1Api = mock<AppsV1Api>()
ferruhcihan marked this conversation as resolved.
Show resolved Hide resolved
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 () => {
// @ts-ignore
mockAppsV1Api.readNamespacedDeployment.mockResolvedValue({ body: null })

ferruhcihan marked this conversation as resolved.
Show resolved Hide resolved
await expect(isOAuth2ProxyRunning(mockAppsV1Api)).rejects.toThrow('OAuth2 Proxy deployment not found, waiting...')
// eslint-disable-next-line @typescript-eslint/unbound-method
expect(mockAppsV1Api.readNamespacedDeployment).toHaveBeenCalledWith('oauth2-proxy', 'istio-system')
})

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

await expect(isOAuth2ProxyRunning(mockAppsV1Api)).rejects.toThrow('OAuth2 Proxy has no status, waiting...')
// eslint-disable-next-line @typescript-eslint/unbound-method
expect(mockAppsV1Api.readNamespacedDeployment).toHaveBeenCalledWith('oauth2-proxy', 'istio-system')
})

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

await expect(isOAuth2ProxyRunning(mockAppsV1Api)).rejects.toThrow(
'OAuth2 Proxy has no available replicas, waiting...',
)
// eslint-disable-next-line @typescript-eslint/unbound-method
expect(mockAppsV1Api.readNamespacedDeployment).toHaveBeenCalledWith('oauth2-proxy', 'istio-system')
})

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

await expect(isOAuth2ProxyRunning(mockAppsV1Api)).rejects.toThrow(
'OAuth2 Proxy has no available replicas, waiting...',
)
// eslint-disable-next-line @typescript-eslint/unbound-method
expect(mockAppsV1Api.readNamespacedDeployment).toHaveBeenCalledWith('oauth2-proxy', 'istio-system')
})

it('should throw an error if the OAuth2 Proxy deployment has no ready replicas', async () => {
ferruhcihan marked this conversation as resolved.
Show resolved Hide resolved
// @ts-ignore
mockAppsV1Api.readNamespacedDeployment.mockResolvedValue({
body: { status: { replicas: 1, unavailableReplicas: 1 } },
})

await expect(isOAuth2ProxyRunning(mockAppsV1Api)).rejects.toThrow(
'OAuth2 Proxy has no available replicas, waiting...',
)
// eslint-disable-next-line @typescript-eslint/unbound-method
expect(mockAppsV1Api.readNamespacedDeployment).toHaveBeenCalledWith('oauth2-proxy', 'istio-system')
})

it('should throw an error if the OAuth2 Proxy deployment has no ready replicas', async () => {
// @ts-ignore
mockAppsV1Api.readNamespacedDeployment.mockResolvedValue({
body: { status: { replicas: 2, unavailableReplicas: 1, availableReplicas: 1 } },
})
ferruhcihan marked this conversation as resolved.
Show resolved Hide resolved

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

expect(mockTerminalInfo).toHaveBeenCalledWith('OAuth2proxy is running, continuing...')
// eslint-disable-next-line @typescript-eslint/unbound-method
expect(mockAppsV1Api.readNamespacedDeployment).toHaveBeenCalledWith('oauth2-proxy', 'istio-system')
})

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

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

expect(mockTerminalInfo).toHaveBeenCalledWith('OAuth2proxy is running, continuing...')
// eslint-disable-next-line @typescript-eslint/unbound-method
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
53 changes: 53 additions & 0 deletions src/common/k8s.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { CoreV1Api } from '@kubernetes/client-node'
import { jest } from '@jest/globals'
import { mock } from 'jest-mock-extended'
import * as k8s from './k8s'

describe('createGenericSecret', () => {
const mockCoreV1Api = mock<CoreV1Api>()

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

it('should create a secret with base64-encoded data', async () => {
const name = 'test-secret'
const namespace = 'default'
const secretData = {
username: 'admin',
password: 'password123',
}
const encodedData = {
username: 'YWRtaW4=', // base64 of 'admin'
password: 'cGFzc3dvcmQxMjM=', // base64 of 'password123'
}

const mockResponse = { body: { metadata: { name, namespace }, data: encodedData } }
// @ts-ignore
mockCoreV1Api.createNamespacedSecret.mockResolvedValue(mockResponse)
const result = await k8s.createGenericSecret(mockCoreV1Api, name, namespace, secretData)

// eslint-disable-next-line @typescript-eslint/unbound-method
expect(mockCoreV1Api.createNamespacedSecret).toHaveBeenCalledWith(namespace, {
metadata: { name, namespace },
data: encodedData,
type: 'Opaque',
})

expect(result).toEqual(mockResponse.body)
})

it('should throw an error if the secret creation fails', async () => {
const name = 'test-secret'
const namespace = 'default'
const secretData = {
username: 'admin',
password: 'password123',
}

const errorMessage = 'Failed to create secret'
mockCoreV1Api.createNamespacedSecret.mockRejectedValue(new Error(errorMessage))

await expect(k8s.createGenericSecret(mockCoreV1Api, name, namespace, secretData)).rejects.toThrow(errorMessage)
})
})
Loading