Skip to content
This repository was archived by the owner on Jul 30, 2025. It is now read-only.

Commit cb3e1c2

Browse files
committed
fix(plugins/plugin-kubectl): kubectl watch tables can be misparsed
Fixes #4139
1 parent 5267e52 commit cb3e1c2

File tree

4 files changed

+76
-67
lines changed

4 files changed

+76
-67
lines changed

packages/core/src/core/jobs/watchable.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@ export interface Watchable {
3333

3434
/** callbacks to indicate state changes */
3535
export interface WatchPusher {
36-
update: (response: Row) => void
36+
update: (response: Row, batch?: boolean) => void
37+
batchUpdateDone: () => void
38+
3739
offline: (rowKey: string) => void
3840

3941
done: () => void

plugins/plugin-client-common/src/components/Table/LivePaginatedTable.tsx

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import * as React from 'react'
1818
import { Table as KuiTable, Row as KuiRow, Watchable } from '@kui-shell/core'
1919

2020
import PaginatedTable, { Props, State } from './PaginatedTable'
21-
import { kuiHeader2carbonHeader, kuiRow2carbonRow } from './kui2carbon'
21+
import { kuiHeader2carbonHeader, kuiRow2carbonRow, NamedDataTableRow } from './kui2carbon'
2222

2323
type LiveProps = Props<KuiTable & Watchable>
2424

@@ -27,6 +27,9 @@ interface LiveState extends State {
2727
}
2828

2929
export default class LivePaginatedTable extends PaginatedTable<LiveProps, LiveState> {
30+
/** To allow for batch updates, the setState can be deferred until a call to updateDone() */
31+
private _deferredUpdate: NamedDataTableRow[]
32+
3033
public constructor(props: LiveProps) {
3134
super(props)
3235
this.state = Object.assign(this.state, { isWatching: true })
@@ -60,11 +63,12 @@ export default class LivePaginatedTable extends PaginatedTable<LiveProps, LiveSt
6063

6164
// initiate the pusher watch
6265
const update = this.update.bind(this)
66+
const batchUpdateDone = this.batchUpdateDone.bind(this)
6367
const offline = this.offline.bind(this)
6468
const done = this.done.bind(this)
6569
const allOffline = this.allOffline.bind(this)
6670
const header = this.header.bind(this)
67-
this.props.response.watch.init({ update, offline, done, allOffline, header })
71+
this.props.response.watch.init({ update, batchUpdateDone, offline, done, allOffline, header })
6872
}
6973

7074
/**
@@ -112,7 +116,7 @@ export default class LivePaginatedTable extends PaginatedTable<LiveProps, LiveSt
112116
* update consumes the update notification and apply it to the table view
113117
*
114118
*/
115-
private update(newKuiRow: KuiRow) {
119+
private update(newKuiRow: KuiRow, batch = false) {
116120
const existingRows = this.state.rows
117121
const nRowsBefore = existingRows.length
118122

@@ -139,7 +143,22 @@ export default class LivePaginatedTable extends PaginatedTable<LiveProps, LiveSt
139143
this.props.response.body[foundIndex] = newKuiRow
140144
}
141145

142-
this.setState({ rows: newRows })
146+
if (!batch) {
147+
this.setState({ rows: newRows })
148+
} else if (this._deferredUpdate) {
149+
this._deferredUpdate = this._deferredUpdate.concat(newRows)
150+
} else {
151+
this._deferredUpdate = newRows
152+
}
153+
}
154+
155+
/** End of a deferred batch of updates */
156+
private batchUpdateDone() {
157+
if (this._deferredUpdate) {
158+
const rows = this._deferredUpdate
159+
this._deferredUpdate = undefined
160+
this.setState({ rows })
161+
}
143162
}
144163

145164
/**

plugins/plugin-kubectl/src/controller/kubectl/fqn.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,11 @@ export function kindAndNamespaceOf(resource: KubeResource) {
6666
}
6767

6868
export function fqn(apiVersion: string, kind: string, name: string, namespace: string) {
69-
return `${kindPart(apiVersion, kind)} ${namespace === '<none>' ? '' : `-n ${namespace}`} ${name}`
69+
if (kind === 'Namespace' && apiVersion === 'v1') {
70+
return `${kind} ${name}`
71+
} else {
72+
return `${kindPart(apiVersion, kind)} ${namespace === '<none>' ? '' : `-n ${namespace}`} ${name}`
73+
}
7074
}
7175

7276
export function fqnOf(resource: KubeResource) {

plugins/plugin-kubectl/src/controller/kubectl/watch/get-watch.ts

Lines changed: 45 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -20,55 +20,50 @@ import { Table, Arguments, CodedError, Streamable, Abortable, Watchable, Watcher
2020
import fqn from '../fqn'
2121
import { formatOf, KubeOptions, KubeExecOptions } from '../options'
2222

23-
import { preprocessTable, Pair } from '../../../lib/view/formatTable'
23+
import { Pair } from '../../../lib/view/formatTable'
2424
import { getCommandFromArgs } from '../../../lib/util/util'
2525

2626
const debug = Debug('plugin-kubectl/controller/watch/watcher')
2727

28-
/**
29-
* We might get partial rows back; they will have empty strings in the
30-
* value field.
31-
*
32-
*/
33-
function isFullRow(row: Pair[], nCols: number): boolean {
34-
return row.length === nCols && row.every(_ => _.value.length > 0)
35-
}
36-
37-
/**
38-
* Return the pair of indices that define the extent of "full" rows
39-
* in the given table. Since we are streaming output from kubectl, we
40-
* get back partial rows in any one bundle of bits.
41-
*
42-
*/
43-
function findFullRows(arr: Pair[][], nCols: number): { firstFullIdx: number; lastFullIdx: number } {
44-
if (arr.length > 0) {
45-
// value=NAME means that this is a header column; we know this
46-
// because, below, we are in charge of the schema of the table!
47-
// skip over that here, because we want to find the interval of
48-
// "full" body rows, i.e. excluding the header row
49-
const startIdx = arr.findIndex(row => row.length === nCols && row[0].value !== 'NAME')
28+
function preprocessTable(raw: string, nCols): { rows: Pair[][]; leftover: string } {
29+
const rows = raw.split(/\n/).map(line => {
30+
const cells = line.split(/\|/)
31+
return cells.slice(0, cells.length - 1) // we have a trailing |
32+
})
5033

51-
if (startIdx >= 0) {
52-
for (let idx = startIdx; idx < arr.length; idx++) {
53-
if (isFullRow(arr[idx], nCols)) {
54-
// then we have found the \lower\ bound
55-
for (let jdx = arr.length - 1; jdx >= idx; jdx--) {
56-
if (isFullRow(arr[jdx], nCols)) {
57-
// and now we have found the /upper/ bound
58-
return { firstFullIdx: idx, lastFullIdx: jdx }
59-
}
60-
}
34+
let lastFullRowIdx = rows.length
35+
while (--lastFullRowIdx >= 0) {
36+
if (rows[lastFullRowIdx].length === nCols) {
37+
break
38+
}
39+
}
6140

62-
// hmm, we couldn't find an upper bound, but we did find a
63-
// lower bound
64-
return { firstFullIdx: idx, lastFullIdx: idx }
65-
}
41+
if (lastFullRowIdx < 0) {
42+
return {
43+
leftover: raw,
44+
rows: []
45+
}
46+
} else if (lastFullRowIdx === rows.length - 1) {
47+
return {
48+
leftover: undefined,
49+
rows: rows.map(line => line.map(value => ({ key: value, value }))).filter(_ => _.length > 0)
50+
}
51+
} else {
52+
let lastNewlineIdx = raw.length
53+
while (--lastNewlineIdx >= 0) {
54+
if (raw.charAt(lastNewlineIdx) === '\n') {
55+
break
6656
}
6757
}
68-
}
6958

70-
// hmm, then we couldn't even find a lower bound
71-
return { firstFullIdx: -1, lastFullIdx: -1 }
59+
return {
60+
leftover: raw.slice(lastNewlineIdx),
61+
rows: rows
62+
.slice(0, lastFullRowIdx + 1)
63+
.map(line => line.map(value => ({ key: value, value })))
64+
.filter(_ => _.length > 0)
65+
}
66+
}
7267
}
7368

7469
class KubectlWatcher implements Abortable, Watcher {
@@ -129,7 +124,7 @@ class KubectlWatcher implements Abortable, Watcher {
129124
return async (_: Streamable) => {
130125
if (typeof _ === 'string') {
131126
// <-- strings flowing out of the PTY
132-
debug('streaming pty output', _)
127+
// debug('streaming pty output', _)
133128
if (/not found/.test(_)) {
134129
this.pusher.allOffline()
135130
return
@@ -146,26 +141,11 @@ class KubectlWatcher implements Abortable, Watcher {
146141
this.leftover = undefined
147142

148143
// here is where we turn the raw data into tabular data
149-
const allRows = preprocessTable([rawData])[0]
150-
151-
// find the interval of "full" rows; we may get back partial
152-
// rows, due to the way output streams back to us from the
153-
// underlying PTY
154-
const { firstFullIdx, lastFullIdx } = findFullRows(allRows, this.nCols)
155-
156-
if (lastFullIdx < 0) {
157-
// then we got no full rows
158-
debug('no full rows', _)
159-
this.leftover = _
160-
return
161-
} else if (lastFullIdx < allRows.length - 1) {
162-
// the we got some trailing leftover bits
163-
const lastNewlineIdx = _.lastIndexOf('\n')
164-
this.leftover = _.slice(lastNewlineIdx)
165-
}
144+
const preprocessed = preprocessTable(rawData, this.nCols)
145+
this.leftover = preprocessed.leftover === '\n' ? undefined : preprocessed.leftover
146+
const { rows } = preprocessed
166147

167148
// now process the full rows into table view updates
168-
const rows = allRows.slice(firstFullIdx, lastFullIdx + 1)
169149
const tables = await Promise.all(
170150
rows.map(async row => {
171151
try {
@@ -208,8 +188,12 @@ class KubectlWatcher implements Abortable, Watcher {
208188
if (table) {
209189
table.body.forEach(row => {
210190
// push an update to the table model
211-
this.pusher.update(row)
191+
// true means we want to do a batch update
192+
this.pusher.update(row, true)
212193
})
194+
195+
// batch update done!
196+
this.pusher.batchUpdateDone()
213197
}
214198
})
215199
} else {
@@ -238,7 +222,7 @@ class KubectlWatcher implements Abortable, Watcher {
238222
.replace(/^k(\s)/, 'kubectl$1')
239223
.replace(/--watch=true|-w=true|--watch-only=true|--watch|-w|--watch-only/g, '--watch') // force --watch
240224
.replace(new RegExp(`(-o|--output)(\\s+|=)${this.output}`), '') +
241-
` -o custom-columns=NAME:.metadata.name,KIND:.kind,APIVERSION:.apiVersion,NAMESPACE:.metadata.namespace`
225+
` -o jsonpath='{.metadata.name}{"|"}{.kind}{"|"}{.apiVersion}{"|"}{.metadata.namespace}{"|\\n"}'`
242226
// ^^^^^ keep these in sync with nCols above !!
243227

244228
this.args.REPL.qexec(`sendtopty ${command}`, this.args.block, undefined, {

0 commit comments

Comments
 (0)