Skip to content

Commit

Permalink
feat(plugins/plugin-kubectl): add support for kustomize apply/delete/…
Browse files Browse the repository at this point in the history
…create

Fixes #4203
  • Loading branch information
starpit committed Apr 6, 2020
1 parent 8576458 commit b95cbdb
Show file tree
Hide file tree
Showing 16 changed files with 383 additions and 24 deletions.
2 changes: 1 addition & 1 deletion plugins/plugin-ibmcloud/plugin/src/controller/available.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export default async function getAvailablePlugins(
tab: Tab,
url = defaultURL
): Promise<{ plugins: AvailablePluginRaw[] }> {
return JSON.parse((await fetchFileString(tab, `${url}/plugins`))[0])
return JSON.parse((await fetchFileString(tab.REPL, `${url}/plugins`))[0])
}

/**
Expand Down
3 changes: 3 additions & 0 deletions plugins/plugin-kubectl/i18n/kustomize_en_US.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"Raw Data": "Raw Data"
}
56 changes: 52 additions & 4 deletions plugins/plugin-kubectl/src/controller/fetch-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,54 @@
* limitations under the License.
*/

import { Registrar } from '@kui-shell/core'
import { Arguments, ParsedOptions, Registrar, REPL } from '@kui-shell/core'

import commandPrefix from './command-prefix'
import { fetchFileString } from '../lib/util/fetch-file'

interface Options extends ParsedOptions {
kustomize?: boolean
}

async function isFile(filepath: string): Promise<boolean> {
const { lstat } = await import('fs')
return new Promise((resolve, reject) => {
lstat(filepath, (err, stats) => {
if (err) {
if (err.code === 'ENOENT') {
resolve(false)
} else {
reject(err)
}
} else {
resolve(stats.isFile())
}
})
})
}

async function fetchKustomizeString(repl: REPL, uri: string): Promise<{ data: string; dir?: string }> {
const [isFile0, { join }] = await Promise.all([isFile(uri), import('path')])

if (isFile0) {
return { data: await fetchFileString(repl, uri)[0] }
} else {
const k1 = join(uri, 'kustomization.yml')
const k2 = join(uri, 'kustomization.yaml')
const k3 = join(uri, 'Kustomization')

const [isFile1, isFile2, isFile3] = await Promise.all([isFile(k1), isFile(k2), isFile(k3)])
const dir = uri // if we are here, then `uri` is a directory
if (isFile1) {
return { data: (await fetchFileString(repl, k1))[0], dir }
} else if (isFile2) {
return { data: (await fetchFileString(repl, k2))[0], dir }
} else if (isFile3) {
return { data: (await fetchFileString(repl, k3))[0], dir }
}
}
}

/**
* A server-side shim to allow browser-based clients to fetch `-f`
* file content.
Expand All @@ -27,10 +70,15 @@ import { fetchFileString } from '../lib/util/fetch-file'
export default (registrar: Registrar) => {
registrar.listen(
`/${commandPrefix}/_fetchfile`,
async ({ argvNoOptions, tab }) => {
async ({ argvNoOptions, parsedOptions, REPL }: Arguments<Options>) => {
const uri = argvNoOptions[argvNoOptions.indexOf('_fetchfile') + 1]
return fetchFileString(tab, uri)

if (!parsedOptions.kustomize) {
return fetchFileString(REPL, uri)
} else {
return { mode: 'raw', content: await fetchKustomizeString(REPL, uri) }
}
},
{ requiresLocal: true }
{ requiresLocal: true, flags: { boolean: ['kustomize'] } }
)
}
5 changes: 2 additions & 3 deletions plugins/plugin-kubectl/src/controller/kubectl/exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import {

import RawResponse from './response'
import commandPrefix from '../command-prefix'
import { KubeOptions, getNamespaceForArgv, getContextForArgv, fileOf } from './options'
import { KubeOptions, getNamespaceForArgv, getContextForArgv, getFileForArgv } from './options'

import { FinalState } from '../../lib/model/states'
import { stringToTable, KubeTableResponse } from '../../lib/view/formatTable'
Expand All @@ -48,8 +48,7 @@ export type PrepareForStatus<O extends KubeOptions> = (cmd: string, args: Argume
/** Standard status preparation */
function DefaultPrepareForStatus<O extends KubeOptions>(cmd: string, args: Arguments<O>) {
const rest = args.argvNoOptions.slice(args.argvNoOptions.indexOf(cmd) + 1).join(' ')
const file = fileOf(args)
return file ? `-f ${fileOf(args)} ${rest}` : rest
return `${getFileForArgv(args, true)}${rest}`
}

/**
Expand Down
2 changes: 1 addition & 1 deletion plugins/plugin-kubectl/src/controller/kubectl/fqn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ function kindPart(apiVersion: string, kind: string) {
return `${kind}${versionString(apiVersion)}`
}

function kindPartOf(resource: KubeResource) {
export function kindPartOf(resource: KubeResource) {
return kindPart(resource.apiVersion, resource.kind)
}

Expand Down
98 changes: 98 additions & 0 deletions plugins/plugin-kubectl/src/controller/kubectl/kustomize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/*
* Copyright 2019 IBM Corporation
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { resolve, basename } from 'path'
import { Arguments, Menu, Registrar, i18n } from '@kui-shell/core'

import flags from './flags'
import { kindPartOf } from './fqn'
import { KubeOptions } from './options'
import { doExecWithStdout } from './exec'
import commandPrefix from '../command-prefix'
import { KubeResource } from '../../lib/model/resource'

import { isUsage, doHelp } from '../../lib/util/help'

const strings = i18n('plugin-kubectl', 'kustomize')

function groupByKind(resources: KubeResource[], rawFull: string): Menu[] {
const rawSplit = rawFull.split(/---/)

const groups = resources.reduce((groups, resource, idx) => {
const key = kindPartOf(resource)

const group = groups[key]
if (!group) {
groups[key] = {
modes: []
}
}

groups[key].modes.push({
mode: resource.metadata.name,
content: rawSplit[idx].replace(/^\n/, ''),
contentType: 'yaml'
})

return groups
}, {} as Menu)

const rawMenu: Menu = {
[strings('Raw Data')]: {
modes: [
{
mode: 'YAML',
content: rawFull,
contentType: 'yaml'
}
]
}
}

// align to the somewhat odd NavResponse Menu model
return Object.keys(groups)
.map(group => ({
[group]: groups[group]
}))
.concat([rawMenu])
}

export const doKustomize = (command = 'kubectl') => async (args: Arguments<KubeOptions>) => {
if (isUsage(args)) {
return doHelp(command, args)
} else {
const [yaml, { safeLoadAll }] = await Promise.all([doExecWithStdout(args, undefined, command), import('js-yaml')])
try {
const resources = safeLoadAll(yaml)
const inputFile = resolve(args.argvNoOptions[args.argvNoOptions.indexOf('kustomize') + 1])

return {
apiVersion: 'kui-shell/v1',
kind: 'NavResponse',
breadcrumbs: [{ label: 'kustomize' }, { label: basename(inputFile), command: `open ${inputFile}` }],
menus: groupByKind(resources, yaml)
}
} catch (err) {
console.error('error preparing kustomize response', err)
return yaml
}
}
}

export default (registrar: Registrar) => {
registrar.listen(`/${commandPrefix}/kubectl/kustomize`, doKustomize(), flags)
registrar.listen(`/${commandPrefix}/k/kustomize`, doKustomize(), flags)
}
21 changes: 21 additions & 0 deletions plugins/plugin-kubectl/src/controller/kubectl/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,24 @@ export function fileOf(args: Arguments<KubeOptions>): string {
return args.parsedOptions.f || args.parsedOptions.filename
}

export function kustomizeOf(args: Arguments<KubeOptions>): string {
return args.parsedOptions.k || args.parsedOptions.kustomize
}

export function getFileForArgv(args: Arguments<KubeOptions>, addSpace = false): string {
const file = fileOf(args)
if (file) {
return `-f ${file}${addSpace ? ' ' : ''}`
} else {
const kusto = kustomizeOf(args)
if (kusto) {
return `-k ${kusto}${addSpace ? ' ' : ''}`
}
}

return ''
}

export function formatOf(args: Arguments<KubeOptions>): OutputFormat {
return args.parsedOptions.o || args.parsedOptions.output
}
Expand Down Expand Up @@ -161,6 +179,9 @@ export interface KubeOptions extends ParsedOptions {
f?: string
filename?: string

k?: string
kustomize?: string

h?: boolean
help?: boolean
}
Expand Down
60 changes: 56 additions & 4 deletions plugins/plugin-kubectl/src/controller/kubectl/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,10 @@ import {

import { flags } from './flags'
import { fqnOfRef, ResourceRef, versionOf } from './fqn'
import { KubeOptions as Options, fileOf, getNamespace, getContextForArgv } from './options'
import { KubeOptions as Options, fileOf, kustomizeOf, getNamespace, getContextForArgv } from './options'
import commandPrefix from '../command-prefix'

import fetchFile from '../../lib/util/fetch-file'
import fetchFile, { fetchFileKustomize } from '../../lib/util/fetch-file'
import KubeResource from '../../lib/model/resource'
import TrafficLight from '../../lib/model/traffic-light'
import { isDone, FinalState } from '../../lib/model/states'
Expand All @@ -61,6 +61,12 @@ const usage = (command: string) => ({
file: true,
docs: 'A kubernetes resource file or kind'
},
{
name: '--kustomize',
alias: '-k',
file: true,
docs: 'A kustomize file or directory'
},
{
name: 'resourceName',
positional: true,
Expand Down Expand Up @@ -100,11 +106,11 @@ const usage = (command: string) => ({
})

/**
* @param file an argument to `-f`, as in `kubectl -f <file>`
* @param file an argument to `-f` or `-k`; e.g. `kubectl -f <file>`
*
*/
async function getResourcesReferencedByFile(file: string, args: Arguments<FinalStateOptions>): Promise<ResourceRef[]> {
const [{ safeLoadAll }, raw] = await Promise.all([import('js-yaml'), fetchFile(args.tab, file)])
const [{ safeLoadAll }, raw] = await Promise.all([import('js-yaml'), fetchFile(args.REPL, file)])

const namespaceFromCommandLine = getNamespace(args) || 'default'

Expand All @@ -121,6 +127,49 @@ async function getResourcesReferencedByFile(file: string, args: Arguments<FinalS
})
}

/**
* @param kusto a kustomize file spec
*
*/
interface Kustomization {
resources?: string[]
}
async function getResourcesReferencedByKustomize(
kusto: string,
args: Arguments<FinalStateOptions>
): Promise<ResourceRef[]> {
const [{ safeLoad }, { join }, raw] = await Promise.all([
import('js-yaml'),
import('path'),
fetchFileKustomize(args.REPL, kusto)
])

const kustomization: Kustomization = safeLoad(raw.data)
if (kustomization.resources) {
const files = await Promise.all(
kustomization.resources.map(resource => {
return fetchFile(args.REPL, raw.dir ? join(raw.dir, resource) : resource)
})
)

return files
.map(raw => safeLoad(raw[0]))
.map(resource => {
const { apiVersion, kind, metadata } = resource
const { group, version } = versionOf(apiVersion)
return {
group,
version,
kind,
name: metadata.name,
namespace: metadata.namespace || getNamespace(args) || 'default'
}
})
}

return []
}

/**
* @param argvRest the argv after `kubectl status`, with options stripped off
*
Expand Down Expand Up @@ -385,13 +434,16 @@ const doStatus = (command: string) => async (args: Arguments<FinalStateOptions>)
const rest = args.argvNoOptions.slice(args.argvNoOptions.indexOf('status') + 1)
const commandArg = command || args.parsedOptions.command
const file = fileOf(args)
const kusto = kustomizeOf(args)
const contextArgs = getContextForArgv(args)
// const fileArgs = file ? `-f ${file}` : ''
// const cmd = `${command} get ${rest} --watch ${fileArgs} ${contextArgs}`

try {
const resourcesToWaitFor = file
? await getResourcesReferencedByFile(file, args)
: kusto
? await getResourcesReferencedByKustomize(kusto, args)
: getResourcesReferencedByCommandLine(rest, args)
debug('resourcesToWaitFor', resourcesToWaitFor)

Expand Down
Loading

0 comments on commit b95cbdb

Please sign in to comment.