├── .prettierrc.js ├── README.md ├── apikey-clientid.png ├── gsheet.js ├── index.html ├── preview.gif └── spreadsheetid.png /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: false, 3 | trailingComma: 'all', 4 | singleQuote: true, 5 | printWidth: 120, 6 | tabWidth: 2, 7 | jsxSingleQuote: true, 8 | }; -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # xlskubectl — a spreadsheet to control your Kubernetes cluster 2 | 3 | xlskubectl integrates Google Spreadsheet with Kubernetes. 4 | 5 | You can finally administer your cluster from the same spreadsheet that you use to track your expenses. 6 | 7 | ![xlskubectl — a spreadsheet to control your Kubernetes cluster](preview.gif) 8 | 9 | ## Usage 10 | 11 | You can start the bridge with: 12 | 13 | ```bash 14 | $ kubectl proxy --www=. 15 | Starting to serve on 127.0.0.1:8001 16 | ``` 17 | 18 | Open the following URL . 19 | 20 | The page will guide through creating the appropriate credentials to connect to Google Spreadsheet. 21 | 22 | ## Frequently Asked Questions 23 | 24 | **Q: What?!** 25 | 26 | A: The following quote best summarises this project: 27 | 28 | > They were so preoccupied with whether or not they could, they didn't stop to think if they should. 29 | 30 | **Q: Not but really, what's going on here?!** 31 | 32 | A: Kubernetes exposes a robust API that is capable of streaming incremental updates. Google Spreadsheet can be scripted to read and write values, so the next logical step is to connect the two (also, [credit to this person](https://www.reddit.com/r/kubernetes/comments/ftgo69/sheet_ops_managing_kubernetes_using_google/)). 33 | 34 | **Q: Is this production-ready?** 35 | 36 | A: We're looking for fundings to take this to the next level. Replacing YAML with spreadsheets has always been our mission as a company, and we will continue to do so. 37 | -------------------------------------------------------------------------------- /apikey-clientid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/learnk8s/xlskubectl/3cd0890e28399bdff8d151468e4488cc127a9ed7/apikey-clientid.png -------------------------------------------------------------------------------- /gsheet.js: -------------------------------------------------------------------------------- 1 | const DISCOVERY_DOCS = ['https://sheets.googleapis.com/$discovery/rest?version=v4'] 2 | const SCOPES = 'https://www.googleapis.com/auth/spreadsheets' 3 | 4 | const connectButton = document.getElementById('connect_button') 5 | const authorizeButton = document.getElementById('authorize_button') 6 | const signoutButton = document.getElementById('signout_button') 7 | 8 | const form = document.getElementById('form') 9 | const authorize = document.getElementById('authorize') 10 | const connected = document.getElementById('connected') 11 | 12 | const clientId = document.getElementById('client-id') 13 | const apiKey = document.getElementById('api-key') 14 | const spreadsheetId = document.getElementById('spreadsheet-id') 15 | 16 | const previousClientId = localStorage.getItem('clientId') 17 | if (!!previousClientId) { 18 | clientId.value = previousClientId 19 | } 20 | const previousApiKey = localStorage.getItem('apiKey') 21 | if (!!previousApiKey) { 22 | apiKey.value = previousApiKey 23 | } 24 | const previousSpreadSheetId = localStorage.getItem('spreadsheetId') 25 | if (!!previousSpreadSheetId) { 26 | spreadsheetId.value = previousSpreadSheetId 27 | } 28 | 29 | let app 30 | let lastResourceVersion 31 | 32 | let params 33 | const MAX_ROWS = 100 34 | const CLIENT_ID = clientId.value 35 | const API_KEY = apiKey.value 36 | 37 | connectButton.addEventListener('click', (event) => { 38 | localStorage.setItem('clientId', clientId.value) 39 | localStorage.setItem('apiKey', apiKey.value) 40 | localStorage.setItem('spreadsheetId', spreadsheetId.value) 41 | gapi.load('client:auth2', initClient) 42 | }) 43 | 44 | function initClient() { 45 | if (clientId.value.trim() === '' || apiKey.value.trim() === '' || spreadsheetId.value.trim() === '') { 46 | return 47 | } 48 | params = { spreadsheetId: spreadsheetId.value } 49 | gapi.client 50 | .init({ 51 | apiKey: apiKey.value.trim(), 52 | clientId: clientId.value.trim(), 53 | discoveryDocs: DISCOVERY_DOCS, 54 | scope: SCOPES, 55 | }) 56 | .then( 57 | function () { 58 | form.style.display = 'none' 59 | // Listen for sign-in state changes. 60 | gapi.auth2.getAuthInstance().isSignedIn.listen(updateSigninStatus) 61 | 62 | // Handle the initial sign-in state. 63 | updateSigninStatus(gapi.auth2.getAuthInstance().isSignedIn.get()) 64 | authorizeButton.onclick = () => gapi.auth2.getAuthInstance().signIn() 65 | signoutButton.onclick = () => gapi.auth2.getAuthInstance().signOut() 66 | }, 67 | function (error) { 68 | form.style.display = 'block' 69 | authorize.style.display = 'none' 70 | connected.style.display = 'none' 71 | appendPre(JSON.stringify(error, null, 2)) 72 | }, 73 | ) 74 | } 75 | 76 | function updateSigninStatus(isSignedIn) { 77 | if (isSignedIn) { 78 | authorize.style.display = 'none' 79 | connected.style.display = 'block' 80 | start() 81 | } else { 82 | authorize.style.display = 'block' 83 | connected.style.display = 'none' 84 | } 85 | } 86 | 87 | function appendPre(message) { 88 | var pre = document.getElementById('content') 89 | var textContent = document.createTextNode(message + '\n') 90 | pre.appendChild(textContent) 91 | } 92 | 93 | function start() { 94 | getAllSheetNames() 95 | .then((sheets) => (app = App(sheets))) 96 | .then(() => { 97 | return fetch('/apis/apps/v1/deployments') 98 | }) 99 | .then((response) => response.json()) 100 | .then((response) => { 101 | const deployments = response.items 102 | lastResourceVersion = response.metadata.resourceVersion 103 | deployments.forEach((pod) => { 104 | const deploymentId = `${pod.metadata.namespace}-${pod.metadata.name}` 105 | app.upsert(deploymentId, pod) 106 | }) 107 | }) 108 | .then(() => streamUpdates()) 109 | 110 | setInterval(() => { 111 | poll() 112 | }, 5000) 113 | } 114 | 115 | function poll() { 116 | getAllSheetNames() 117 | .then((sheets) => { 118 | return gapi.client.sheets.spreadsheets.values.batchGet({ 119 | ...params, 120 | ranges: sheets.filter((it) => it !== 'Sheet1').map((it) => `${it}!A2:C${MAX_ROWS}`), 121 | }) 122 | }) 123 | .then((response) => { 124 | response.result.valueRanges.forEach(({ range, values }) => { 125 | const namespace = range.split('!')[0].replace(/'/g, '') 126 | values.forEach((row) => { 127 | if (row[0] === '') { 128 | return 129 | } 130 | if (row[1] === '') { 131 | return 132 | } 133 | if (`${row[1]}` !== `${row[2]}`) { 134 | console.log('SCALE', row[0], ' in ', namespace) 135 | fetch(`/apis/apps/v1/namespaces/${namespace}/deployments/${row[0]}`, { 136 | method: 'PATCH', 137 | body: JSON.stringify({ spec: { replicas: parseInt(row[1], 10) } }), 138 | headers: { 139 | 'Content-Type': 'application/strategic-merge-patch+json', 140 | }, 141 | }) 142 | } 143 | }) 144 | }) 145 | }) 146 | } 147 | 148 | function streamUpdates() { 149 | fetch(`/apis/apps/v1/deployments?watch=1&resourceVersion=${lastResourceVersion}`) 150 | .then((response) => { 151 | const stream = response.body.getReader() 152 | const utf8Decoder = new TextDecoder('utf-8') 153 | let buffer = '' 154 | 155 | return stream.read().then(function processText({ done, value }) { 156 | if (done) { 157 | console.log('Request terminated') 158 | return 159 | } 160 | buffer += utf8Decoder.decode(value) 161 | buffer = findLine(buffer, (line) => { 162 | if (line.trim().length === 0) { 163 | return 164 | } 165 | try { 166 | const event = JSON.parse(line) 167 | console.log('PROCESSING EVENT: ', event) 168 | const deployment = event.object 169 | const deploymentId = `${deployment.metadata.namespace}-${deployment.metadata.name}` 170 | switch (event.type) { 171 | case 'ADDED': { 172 | app.upsert(deploymentId, deployment) 173 | break 174 | } 175 | case 'DELETED': { 176 | app.remove(deploymentId) 177 | break 178 | } 179 | case 'MODIFIED': { 180 | app.upsert(deploymentId, deployment) 181 | break 182 | } 183 | default: 184 | break 185 | } 186 | lastResourceVersion = deployment.metadata.resourceVersion 187 | } catch (error) { 188 | console.log('Error while parsing', line, '\n', error) 189 | } 190 | }) 191 | return stream.read().then(processText) 192 | }) 193 | }) 194 | .catch(() => { 195 | console.log('Error! Retrying in 5 seconds...') 196 | setTimeout(() => streamUpdates(), 5000) 197 | }) 198 | 199 | function findLine(buffer, fn) { 200 | const newLineIndex = buffer.indexOf('\n') 201 | if (newLineIndex === -1) { 202 | return buffer 203 | } 204 | const chunk = buffer.slice(0, buffer.indexOf('\n')) 205 | const newBuffer = buffer.slice(buffer.indexOf('\n') + 1) 206 | fn(chunk) 207 | return findLine(newBuffer, fn) 208 | } 209 | } 210 | 211 | function renderSheet(rows, sheetName) { 212 | return gapi.client.sheets.spreadsheets.values.batchUpdate(params, { 213 | valueInputOption: 'RAW', 214 | data: [ 215 | { 216 | range: `${sheetName}!A1:A${rows.length + 1}`, 217 | values: [['Deployment'], ...rows.map((it) => [it.name])], 218 | }, 219 | { 220 | range: `${sheetName}!B1`, 221 | values: [['Desired']], 222 | }, 223 | { 224 | range: `${sheetName}!C1:C${rows.length + 1}`, 225 | values: [['Actual'], ...rows.map((it) => [it.replicas])], 226 | }, 227 | { 228 | range: `${sheetName}!A${rows.length + 2}:C${MAX_ROWS}`, 229 | values: [...range(MAX_ROWS - rows.length - 1).map((i) => ['', '', ''])], 230 | }, 231 | ], 232 | }) 233 | } 234 | 235 | function createSheets(titles) { 236 | if (titles.length === 0) { 237 | return Promise.resolve() 238 | } 239 | return gapi.client.sheets.spreadsheets.batchUpdate(params, { 240 | requests: titles.map((it) => ({ 241 | addSheet: { 242 | properties: { 243 | title: it, 244 | }, 245 | }, 246 | })), 247 | }) 248 | } 249 | 250 | function deleteSheets(sheetNames) { 251 | if (sheetNames.length === 0) { 252 | return Promise.resolve() 253 | } 254 | return gapi.client.sheets.spreadsheets 255 | .get(params) 256 | .then((response) => { 257 | const mappings = response.result.sheets.reduce((acc, it) => { 258 | acc[it.properties.title] = it.properties.sheetId 259 | return acc 260 | }, {}) 261 | return sheetNames.map((it) => mappings[it]).filter((it) => !!it) 262 | }) 263 | .then((sheetIds) => { 264 | return gapi.client.sheets.spreadsheets.batchUpdate(params, { 265 | requests: sheetIds.map((it) => ({ 266 | deleteSheet: { 267 | sheetId: it, 268 | }, 269 | })), 270 | }) 271 | }) 272 | .catch(() => {}) 273 | } 274 | 275 | function getAllSheetNames() { 276 | return gapi.client.sheets.spreadsheets.get(params).then((it) => it.result.sheets.map((it) => it.properties.title)) 277 | } 278 | 279 | function range(n) { 280 | return [...Array(n).keys()] 281 | } 282 | 283 | function App(sheets = ['Sheet1']) { 284 | const allDeployments = new Map() 285 | 286 | async function render() { 287 | const deployments = Array.from(allDeployments.values()) 288 | if (deployments.length !== 0) { 289 | const deploymentsByNamespace = groupBy(deployments, (it) => it.namespace) 290 | const namespaces = Object.keys(deploymentsByNamespace) 291 | const { added, removed } = diff({ previous: sheets, current: ['Sheet1', ...namespaces] }) 292 | added.forEach((it) => sheets.push(it)) 293 | removed.forEach((it) => { 294 | const index = sheets.findIndex((sheet) => sheet === it) 295 | sheets.splice(index, 1) 296 | }) 297 | try { 298 | await Promise.all([createSheets(added), deleteSheets(removed)]) 299 | await Promise.all( 300 | namespaces.map((namespace) => { 301 | return renderSheet(deploymentsByNamespace[namespace], namespace) 302 | }), 303 | ) 304 | } catch (error) { 305 | console.log('ERROR in rendering', error) 306 | } 307 | } 308 | setTimeout(render, 1500) 309 | } 310 | 311 | render() 312 | 313 | return { 314 | upsert(deploymentId, deployment) { 315 | allDeployments.set(deploymentId, { 316 | name: deployment.metadata.name, 317 | namespace: deployment.metadata.namespace, 318 | replicas: deployment.spec.replicas, 319 | }) 320 | }, 321 | remove(deploymentId) { 322 | allDeployments.delete(deploymentId) 323 | }, 324 | } 325 | } 326 | 327 | function groupBy(arr, groupByKeyFn) { 328 | return arr.reduce((acc, c) => { 329 | const key = groupByKeyFn(c) 330 | if (!(key in acc)) { 331 | acc[key] = [] 332 | } 333 | acc[key].push(c) 334 | return acc 335 | }, {}) 336 | } 337 | 338 | function diff({ previous, current }) { 339 | const uniqueCurrentIds = current.filter(onlyUnique) 340 | const uniquePreviousIds = previous.filter(onlyUnique) 341 | return { 342 | removed: uniquePreviousIds.filter((a) => uniqueCurrentIds.findIndex((b) => a === b) === -1), 343 | unchanged: uniquePreviousIds.filter((a) => uniqueCurrentIds.findIndex((b) => a === b) > -1), 344 | added: uniqueCurrentIds.filter((b) => uniquePreviousIds.findIndex((a) => a === b) === -1), 345 | } 346 | } 347 | 348 | function onlyUnique(value, index, self) { 349 | return self.map((it) => `${it}`).indexOf(`${value}`) === index 350 | } 351 | 352 | function debounce(func, wait, immediate) { 353 | var timeout 354 | return function () { 355 | var context = this, 356 | args = arguments 357 | var later = function () { 358 | timeout = null 359 | if (!immediate) func.apply(context, args) 360 | } 361 | var callNow = immediate && !timeout 362 | clearTimeout(timeout) 363 | timeout = setTimeout(later, wait) 364 | if (callNow) func.apply(context, args) 365 | } 366 | } 367 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | xlskubectl 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 |

xlskubectl bridge

14 | 15 |
16 |
    17 |
  1. 18 | 19 | 21 |
  2. 22 |
  3. 23 | 24 | 26 |
  4. 27 |
  5. 28 | 29 | 31 |
  6. 32 |
  7. 33 | 34 |
  8. 35 |
36 |
37 |

How to set up the inegration with Google Sheets

38 |

You can generate Client ID and API key from the following link: Google Sheets Quickstart. 40 |

41 |
42 | 43 |
44 |

45 | You can retrieve the Spreadsheet ID from the url. 46 |

47 |
48 | 49 |
50 |
51 |
52 | 53 |
54 |

Connected. You should authorize the app.

55 | 56 |
57 | 58 |
59 |

Streaming updates.

60 | 61 |
62 | 63 |

64 |   
65 | 66 | 67 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /preview.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/learnk8s/xlskubectl/3cd0890e28399bdff8d151468e4488cc127a9ed7/preview.gif -------------------------------------------------------------------------------- /spreadsheetid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/learnk8s/xlskubectl/3cd0890e28399bdff8d151468e4488cc127a9ed7/spreadsheetid.png --------------------------------------------------------------------------------