@leverege/build-tools 2.115.2 → 2.117.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leverege/build-tools",
3
- "version": "2.115.2",
3
+ "version": "2.117.0",
4
4
  "description": "A collection of build / support tools for Leverege developers",
5
5
  "main": "index.js",
6
6
  "repository": {
@@ -23,7 +23,6 @@
23
23
  "build-tools": "src/build-tools.mjs",
24
24
  "chart-compass": "src/chart-compass.mjs",
25
25
  "chart-to-registry": "src/chart-to-registry.mjs",
26
- "circle-orb-it": "src/circle-orb-it/circle-orb-it.mjs",
27
26
  "clone-cnpg-from-snapshot": "src/clone-cnpg-from-snapshot.mjs",
28
27
  "count-lines-of-code": "src/count-lines-of-code.mjs",
29
28
  "dashboard": "src/dashboard/dashboard.mjs",
package/src/Docker.mjs CHANGED
@@ -148,6 +148,10 @@ const generateDockerfile = async ( options ) => {
148
148
  if ( fs.existsSync( dockerfilePrePlugin ) ) {
149
149
  settings.preInstallPluginfile = fs.readFileSync( dockerfilePrePlugin )
150
150
  }
151
+ const dockerfileFinalStagePrePlugin = './docker/DockerfileFinalStagePreInstall.plugin'
152
+ if ( fs.existsSync( dockerfileFinalStagePrePlugin ) ) {
153
+ settings.finalStagePreInstallPluginfile = fs.readFileSync( dockerfileFinalStagePrePlugin )
154
+ }
151
155
 
152
156
  const compiled = handlebars.compile( dockerfileTemplate, { noEscape : true } )
153
157
  const replaced = compiled( settings )
package/src/Utils.mjs CHANGED
@@ -338,14 +338,14 @@ export const parseJsonFile = async ( jsonFile ) => {
338
338
  }
339
339
  }
340
340
 
341
- // Parses the ./package.json file and verifies there is a properly formatted
342
- // leverege.registry section present.
343
341
  const deprecated = ( opts ) => {
344
342
  log( chalk.red.bold( `\n ***DEPRECATED: ${opts.deprecationError}` ) )
345
343
  log( chalk.white( opts.deprecationInfo ) )
346
344
  process.exit( 1 )
347
345
  }
348
346
 
347
+ // Parses the ./package.json file and verifies there is a properly formatted
348
+ // leverege.registry section present.
349
349
  export const parsePackageJson = async ( packageFileName = './package.json' ) => {
350
350
  if ( !fs.existsSync( packageFileName ) ) { return undefined }
351
351
 
package/src/bash-funcs CHANGED
@@ -55,6 +55,15 @@ function clearlyWarn() {
55
55
  CLEARWARN
56
56
  }
57
57
 
58
+ function btDeprecated() {
59
+ cat<<BT_DEPRECATED
60
+
61
+ `color r "*** DEPRECATED: ${1}"`
62
+
63
+ BT_DEPRECATED
64
+ sleep 3
65
+ }
66
+
58
67
  function warning() {
59
68
  clearlyWarn DO_NOT_CLEAR
60
69
  }
@@ -113,6 +113,23 @@ const buildCompassMap = ( yamlFile ) => {
113
113
  }
114
114
 
115
115
  const chartCompass = YAML.load( fs.readFileSync( yamlFile, 'utf8' ) ) // eslint-disable-line security/detect-non-literal-fs-filename
116
+
117
+ if ( !Array.isArray( chartCompass ) ) {
118
+ errorExit( `${yamlFile}: expected a YAML array at the top level` )
119
+ }
120
+
121
+ for ( const entry of chartCompass ) {
122
+ const repositories = entry.repositories || {}
123
+ for ( const [ repo, chartMap ] of Object.entries( repositories ) ) {
124
+ if ( chartMap === null || typeof chartMap !== 'object' || Array.isArray( chartMap ) ) {
125
+ errorExit(
126
+ `${yamlFile}: repository "${repo}" must be a map of chart entries (each chart key needs ": {}" or explicit config).\n` +
127
+ ` Got: ${JSON.stringify( chartMap )?.slice( 0, 120 )}`
128
+ )
129
+ }
130
+ }
131
+ }
132
+
116
133
  return chartCompass
117
134
  }
118
135
 
@@ -57,6 +57,8 @@ if ( options.version ) {
57
57
  process.exit( 0 )
58
58
  }
59
59
 
60
+ warning( 'circle-orb-it is deprecated. Use github-actions-it instead.' )
61
+
60
62
  const gitRoot = await getGitRootDirectory()
61
63
  if ( !gitRoot ) {
62
64
  errorExit( 'This command must be run inside a git repository' )
@@ -2,7 +2,7 @@
2
2
  #
3
3
  # builds an alpine image with kubectl installed
4
4
  #
5
- KUBECTL_VERSION="1.30.3"
5
+ KUBECTL_VERSION="1.31.0"
6
6
 
7
7
  docker buildx build \
8
8
  --build-arg KUBECTL_VERSION=${KUBECTL_VERSION} \
@@ -129,7 +129,7 @@ const dockerfileOptions = {
129
129
  isNpmWorkspace,
130
130
  buildEnv : leveregeStanza.buildEnv || leveregeStanza['build-env'] || 'alpine',
131
131
  imageBase : leveregeStanza.imageBase || leveregeStanza['image-base'],
132
- runuser : leveregeStanza.runUser || leveregeStanza['run-user'] || isNodeJsProject ? 'node' : 'python',
132
+ runuser : leveregeStanza.runUser || leveregeStanza['run-user'] || ( isNodeJsProject ? 'node' : 'python' ),
133
133
  nodeimage : leveregeStanza.nodeimg, // left as nodeimg in package.json for backwards compat
134
134
  regvers : repoDescr.packageVersion,
135
135
  pythonVersion : repoDescr.rootPyProjectToml?.pythonVersion,
@@ -0,0 +1,155 @@
1
+ #!/usr/bin/env node
2
+ import { $ } from 'zx'
3
+ import { program } from 'commander'
4
+ import chalk from 'chalk'
5
+
6
+ import { enableDebug, errorExit, log } from './Utils.mjs'
7
+
8
+ $.verbose = false
9
+
10
+ const DEFAULT_ORG = 'Leverege'
11
+ const DEFAULT_LIMIT = 50
12
+
13
+ program
14
+ .name( 'gh-audit-log' )
15
+ .description( chalk.green( 'Query the Leverege GitHub org audit log' ) )
16
+ .option( '--org <org>', 'GitHub org', DEFAULT_ORG )
17
+ .option( '--team <slug>', 'Filter to a team (e.g. workwatch-admins) — applied client-side, GitHub has no server-side team qualifier' )
18
+ .option( '--repo <name>', 'Filter to a repo (e.g. tpi-viz) — server-side' )
19
+ .option( '--actor <login>', 'Filter to a specific actor' )
20
+ .option( '--action <phrase>', 'Filter by action prefix (e.g. team, repo, org)' )
21
+ .option( '--since <date>', 'Only show events after this date (YYYY-MM-DD)' )
22
+ .option( '--phrase <raw>', 'Raw audit-log search phrase, combined with the above' )
23
+ .option( '--limit <n>', 'Max events to show', DEFAULT_LIMIT )
24
+ .option( '--json', 'Print raw JSON instead of a formatted table' )
25
+ .option( '--debug', 'Enable debug logging' )
26
+ .addHelpText( 'after', `
27
+ ${chalk.yellow( 'Examples:' )}
28
+ ${chalk.green( 'gh-audit-log --team workwatch-admins' )}
29
+ ${chalk.green( 'gh-audit-log --actor leverege-jphelps --since 2026-08-14' )}
30
+ ${chalk.green( 'gh-audit-log --action repo --since 2026-08-01 --json' )}
31
+
32
+ ${chalk.yellow( 'Notes:' )}
33
+ Requires org-owner access — this hits ${chalk.green( 'GET /orgs/{org}/audit-log' )}, same
34
+ endpoint used throughout the GitHub access-control work this tool grew out of.
35
+
36
+ GitHub's audit-log search has no server-side "team" qualifier, even though events carry
37
+ a team field — ${chalk.green( '--team' )} is matched client-side after fetching.
38
+
39
+ This tool is built for access-control auditing, not as a general org-activity browser.
40
+ Unless you pass --action / --phrase / --repo / --actor, --action defaults to 'team' —
41
+ otherwise an unscoped query pages through the entire org log, which is large even over
42
+ a few days (routine org activity plus a chatty self-hosted-runner bot that registers
43
+ itself hourly). --since also defaults to 7 days ago unless given explicitly. Use
44
+ --action org (or --phrase) to look outside team/permission events, e.g. org role changes.
45
+ ` )
46
+ .parse()
47
+
48
+ const opts = program.opts()
49
+ if ( opts.debug ) { enableDebug(); $.verbose = true }
50
+
51
+ // ---------- Build the audit-log search phrase (server-side qualifiers only) ----------
52
+
53
+ // This tool exists for access-control auditing, not as a general org-activity browser —
54
+ // default to action:team (team/permission changes) unless the caller opts into something
55
+ // broader via --action/--phrase/--repo/--actor. Without this, an unscoped query pages
56
+ // through the entire org log, which is large even over a few days (routine org activity
57
+ // plus a chatty self-hosted-runner bot that registers hourly).
58
+ const hasExplicitScope = opts.action || opts.phrase || opts.repo || opts.actor
59
+ const action = opts.action ?? ( !opts.phrase && !hasExplicitScope ? 'team' : undefined )
60
+ const since = opts.since ?? sevenDaysAgo()
61
+
62
+ const phraseParts = []
63
+ if ( opts.repo ) { phraseParts.push( `repo:${opts.org}/${opts.repo}` ) }
64
+ if ( opts.actor ) { phraseParts.push( `actor:${opts.actor}` ) }
65
+ if ( action ) { phraseParts.push( `action:${action}` ) }
66
+ if ( since ) { phraseParts.push( `created:>${since}` ) }
67
+ if ( opts.phrase ) { phraseParts.push( opts.phrase ) }
68
+ const phrase = phraseParts.join( ' ' )
69
+
70
+ function sevenDaysAgo() {
71
+ const d = new Date()
72
+ d.setDate( d.getDate() - 7 )
73
+ return d.toISOString().slice( 0, 10 )
74
+ }
75
+
76
+ // ---------- Fetch ----------
77
+
78
+ async function fetchAuditLog( { org, phrase : searchPhrase } ) {
79
+ const phraseArgs = searchPhrase ? [ '-f', `phrase=${searchPhrase}` ] : []
80
+
81
+ let stdout
82
+ try {
83
+ ( { stdout } = await $`gh api orgs/${org}/audit-log -X GET --paginate ${phraseArgs}` )
84
+ } catch ( error ) {
85
+ errorExit( `Failed to query audit log: ${error.stderr ?? error.message ?? error}` )
86
+ }
87
+
88
+ // `gh api --paginate` concatenates one JSON array per page back-to-back —
89
+ // split and re-flatten rather than assuming a single JSON document.
90
+ const pages = stdout
91
+ .split( /(?<=\])\s*(?=\[)/ )
92
+ .filter( chunk => chunk.trim().length > 0 )
93
+ .map( chunk => JSON.parse( chunk ) )
94
+
95
+ return pages.flat()
96
+ }
97
+
98
+ const fetched = await fetchAuditLog( { org : opts.org, phrase } )
99
+ const events = opts.team ?
100
+ fetched.filter( e => e.team === `${opts.org}/${opts.team}` ) :
101
+ fetched
102
+ const limited = events.slice( 0, Number( opts.limit ) )
103
+
104
+ if ( opts.json ) {
105
+ log( JSON.stringify( limited, null, 2 ) )
106
+ process.exit( 0 )
107
+ }
108
+
109
+ if ( limited.length === 0 ) {
110
+ log( chalk.yellow( 'No matching audit log events found.' ) )
111
+ process.exit( 0 )
112
+ }
113
+
114
+ // ---------- Formatted table ----------
115
+
116
+ const rows = limited.map( e => ( {
117
+ when : new Date( e['@timestamp'] ).toLocaleString(),
118
+ actor : e.actor ?? '-',
119
+ action : e.action ?? '-',
120
+ team : e.team ? e.team.replace( `${opts.org}/`, '' ) : '-',
121
+ repo : e.repo ? e.repo.replace( `${opts.org}/`, '' ) : '-',
122
+ permission : e.permission ?? '-',
123
+ } ) )
124
+
125
+ const widths = [ 'when', 'actor', 'action', 'team', 'repo', 'permission' ].reduce( ( acc, key ) => {
126
+ acc[key] = Math.max( key.length, ...rows.map( r => String( r[key] ).length ) )
127
+ return acc
128
+ }, {} )
129
+
130
+ const PERMISSION_COLORS = {
131
+ admin : chalk.red,
132
+ maintain : chalk.yellow,
133
+ write : chalk.green,
134
+ triage : chalk.gray,
135
+ read : chalk.gray,
136
+ }
137
+
138
+ const printRow = ( row, { header = false } = {} ) => {
139
+ const cells = Object.entries( widths ).map( ( [ key, width ] ) => {
140
+ // pad the plain text first — ANSI color codes have zero visible width but
141
+ // count toward .length, so padding *after* colorizing would misalign columns
142
+ const padded = String( row[key] ).padEnd( width )
143
+ if ( header ) { return chalk.bold( padded ) }
144
+ if ( key === 'permission' ) { return ( PERMISSION_COLORS[row[key]] ?? chalk.dim )( padded ) }
145
+ if ( key === 'actor' ) { return row[key].endsWith( '[bot]' ) ? chalk.dim( padded ) : padded }
146
+ if ( key === 'when' ) { return chalk.dim( padded ) }
147
+ return padded
148
+ } )
149
+ log( cells.join( ' ' ) )
150
+ }
151
+
152
+ printRow( { when : 'WHEN', actor : 'ACTOR', action : 'ACTION', team : 'TEAM', repo : 'REPO', permission : 'PERM' }, { header : true } )
153
+ rows.forEach( row => printRow( row ) )
154
+
155
+ log( chalk.dim( `\n${limited.length} of ${events.length} matching events shown (--limit ${opts.limit} to change)` ) )
@@ -18,12 +18,24 @@ import {
18
18
  } from './Utils.mjs'
19
19
 
20
20
  const GH_ACTIONS_REF = 'development'
21
- const CIRCLE_YML = '.circleci/config.yml'
22
21
  const GH_WORKFLOWS_DIR = '.github/workflows'
23
22
  const GH_CI_YML = `${GH_WORKFLOWS_DIR}/ci.yml`
23
+ const GH_DEPENDABOT_YML = '.github/dependabot.yml'
24
24
  const DEFAULT_COVERAGE_THRESHOLD = 70
25
25
  const DEFAULT_CACHE_CONFIG = '{"type":"inMemory"}'
26
26
 
27
+ const DEPENDABOT_CONTENT = `version: 2
28
+ updates:
29
+ - package-ecosystem: "npm"
30
+ directory: "/"
31
+ schedule:
32
+ interval: "weekly"
33
+ open-pull-requests-limit: 10
34
+ ignore:
35
+ - dependency-name: "eslint"
36
+ versions: [">=10"]
37
+ `
38
+
27
39
  program
28
40
  .name( 'github-actions-it' )
29
41
  .description( chalk.green( 'Installs or updates GitHub Actions CI configuration for a Leverege library repository' ) )
@@ -31,11 +43,12 @@ program
31
43
  .option( '--dry-run', 'Show what would be written without making changes' )
32
44
  .addHelpText( 'after', `
33
45
  ${chalk.yellow( 'Files managed:' )}
34
- ${chalk.green( GH_CI_YML )} GitHub Actions CI workflow
46
+ ${chalk.green( GH_CI_YML )} GitHub Actions CI workflow
47
+ ${chalk.green( GH_DEPENDABOT_YML )} weekly npm dependency updates
35
48
 
36
49
  ${chalk.yellow( 'Notes:' )}
37
50
  Run from anywhere inside a git repository.
38
- Reads .circleci/config.yml to preserve existing settings when present.
51
+ Preserves settings from an existing ci.yml when updating.
39
52
  Redis is always available on port 6379 — no configuration needed.
40
53
  Yarn auth and nodeLinker are handled by the workflow writing ~/.yarnrc.yml on the runner.
41
54
 
@@ -53,60 +66,11 @@ if ( !gitRoot ) {
53
66
  errorExit( 'This command must be run inside a git repository' )
54
67
  }
55
68
 
56
- // ---------- Parse existing CircleCI config ----------
57
-
58
- const circleYmlPath = path.join( gitRoot, CIRCLE_YML )
69
+ // ---------- Defaults ----------
59
70
 
60
71
  let enableGcp = false
61
72
  let coverageThreshold = DEFAULT_COVERAGE_THRESHOLD
62
73
  let cacheConfig = DEFAULT_CACHE_CONFIG
63
- let redisEnabled = false
64
- let isYarnFromCircle = false
65
-
66
- if ( fs.existsSync( circleYmlPath ) ) {
67
- const circleConfig = YAML.load( fs.readFileSync( circleYmlPath, 'utf8' ) )
68
-
69
- const allJobs = []
70
- for ( const workflow of Object.values( circleConfig.workflows ?? {} ) ) {
71
- for ( const job of ( workflow.jobs ?? [] ) ) {
72
- if ( typeof job === 'object' ) {
73
- for ( const [ name, config ] of Object.entries( job ) ) {
74
- allJobs.push( { name, config: config ?? {} } )
75
- }
76
- }
77
- }
78
- }
79
-
80
- const coverageJob = allJobs.find( j => j.name === 'leverege/code-coverage' )
81
- if ( coverageJob ) {
82
- const { config } = coverageJob
83
- coverageThreshold = config['coverage-threshold'] ?? DEFAULT_COVERAGE_THRESHOLD
84
- cacheConfig = config['cache-config'] ?? DEFAULT_CACHE_CONFIG
85
- redisEnabled = config['enable-redis'] === true
86
-
87
- const ctx = config.context
88
- const contexts = Array.isArray( ctx ) ? ctx : ( ctx ? [ ctx ] : [] )
89
- enableGcp = contexts.includes( 'leverege-gcp' )
90
- }
91
-
92
- isYarnFromCircle = allJobs.some( j => j.config['pkg-manager'] === 'yarn-berry' )
93
-
94
- log( chalk.blue( '\nParsed CircleCI config:' ) )
95
- log( ` pkg-manager: ${isYarnFromCircle ? 'yarn-berry' : 'npm'}` )
96
- log( ` enable-gcp: ${enableGcp}` )
97
- log( ` coverage-threshold: ${coverageThreshold}` )
98
- log( ` cache-config: ${cacheConfig}` )
99
- log( ` enable-redis: ${redisEnabled}` )
100
- } else {
101
- log( chalk.yellow( '\nNo .circleci/config.yml found — using defaults' ) )
102
- }
103
-
104
- const isYarn = isYarnFromCircle || getIsYarnProject( gitRoot )
105
- log( chalk.blue( `\nPackage manager: ${isYarn ? 'yarn-berry' : 'npm'}` ) )
106
-
107
- if ( redisEnabled ) {
108
- log( chalk.yellow( '\nRedis detected — Redis service is always available on port 6379 in GitHub Actions' ) )
109
- }
110
74
 
111
75
  // ---------- Determine what needs writing ----------
112
76
 
@@ -116,13 +80,33 @@ let ciYmlExists = false
116
80
  if ( fs.existsSync( ciYmlPath ) ) {
117
81
  ciYmlExists = true
118
82
  const existingCi = fs.readFileSync( ciYmlPath, 'utf8' )
119
- if ( existingCi.includes( `library-ci.yml@${GH_ACTIONS_REF}` ) ) {
83
+ if ( existingCi.includes( `node-ci.yml@${GH_ACTIONS_REF}` ) ) {
120
84
  log( chalk.yellow( '\nExisting GitHub Actions CI config found — will update' ) )
85
+ try {
86
+ const existingParsed = YAML.load( existingCi )
87
+ const existingWith = Object.values( existingParsed?.jobs ?? {} )[0]?.with ?? {}
88
+ if ( existingWith['enable-gcp'] != null ) enableGcp = existingWith['enable-gcp']
89
+ if ( existingWith['coverage-threshold'] != null ) coverageThreshold = existingWith['coverage-threshold']
90
+ if ( existingWith['cache-config'] != null ) cacheConfig = existingWith['cache-config']
91
+ log( chalk.blue( '\nPreserved from existing ci.yml:' ) )
92
+ log( ` enable-gcp: ${enableGcp}` )
93
+ log( ` coverage-threshold: ${coverageThreshold}` )
94
+ log( ` cache-config: ${cacheConfig}` )
95
+ } catch {
96
+ log( chalk.yellow( 'Could not parse existing ci.yml — using defaults' ) )
97
+ }
121
98
  } else {
122
99
  log( chalk.yellow( '\nExisting .github/workflows/ci.yml found (not managed by github-actions-it) — will overwrite' ) )
123
100
  }
124
101
  }
125
102
 
103
+ if ( fs.existsSync( path.join( gitRoot, '.circleci/config.yml' ) ) ) {
104
+ log( chalk.yellow( '\n*** WARNING: .circleci/config.yml detected — CircleCI is deprecated. Remove it once GitHub Actions CI is confirmed green.\n' ) )
105
+ }
106
+
107
+ const isYarn = getIsYarnProject( gitRoot )
108
+ log( chalk.blue( `\nPackage manager: ${isYarn ? 'yarn-berry' : 'npm'}` ) )
109
+
126
110
  // ---------- Generate content ----------
127
111
 
128
112
  function generateCiYml() {
@@ -147,11 +131,11 @@ function generateCiYml() {
147
131
  ' workflow_dispatch:',
148
132
  '',
149
133
  'jobs:',
150
- ' ci:',
134
+ ' node-ci:',
151
135
  ' permissions:',
152
136
  ' id-token: write',
153
137
  ' contents: read',
154
- ` uses: Leverege/github-actions/.github/workflows/library-ci.yml@${GH_ACTIONS_REF}`,
138
+ ` uses: Leverege/github-actions/.github/workflows/node-ci.yml@${GH_ACTIONS_REF}`,
155
139
  ]
156
140
 
157
141
  if ( withLines.length > 0 ) {
@@ -168,6 +152,8 @@ function generateCiYml() {
168
152
  if ( options.dryRun ) {
169
153
  log( chalk.green( `\n[dry-run] Would write ${GH_CI_YML}:\n` ) )
170
154
  log( generateCiYml() )
155
+ log( chalk.green( `\n[dry-run] Would write ${GH_DEPENDABOT_YML}:\n` ) )
156
+ log( DEPENDABOT_CONTENT )
171
157
  process.exit( 0 )
172
158
  }
173
159
 
@@ -183,5 +169,9 @@ mkdirSafe( path.join( gitRoot, GH_WORKFLOWS_DIR ) )
183
169
  fs.writeFileSync( ciYmlPath, generateCiYml() )
184
170
  log( chalk.green( `\nGitHub Actions CI config written => ${GH_CI_YML}` ) )
185
171
 
172
+ const dependabotPath = path.join( gitRoot, GH_DEPENDABOT_YML )
173
+ fs.writeFileSync( dependabotPath, DEPENDABOT_CONTENT )
174
+ log( chalk.green( `Dependabot config written => ${GH_DEPENDABOT_YML}` ) )
175
+
186
176
  log( chalk.yellow( '\nNext steps:' ) )
187
- log( ` git add ${GH_CI_YML} && git commit -m "CHORE add GitHub Actions CI"\n` )
177
+ log( ` git add ${GH_CI_YML} ${GH_DEPENDABOT_YML} && git commit -m "CHORE add GitHub Actions CI"\n` )
@@ -8,7 +8,7 @@ showInstalling "The Prometheus Operator (kube-prometheus-stack)"
8
8
 
9
9
  # https://artifacthub.io/packages/helm/prometheus-community/kube-prometheus-stack
10
10
  OCI_CHART="oci://ghcr.io/prometheus-community/charts/kube-prometheus-stack"
11
- [ -z "$PROMETHEUS_STACK_CHART_VERSION" ] && PROMETHEUS_STACK_CHART_VERSION="87.21.0"
11
+ [ -z "$PROMETHEUS_STACK_CHART_VERSION" ] && PROMETHEUS_STACK_CHART_VERSION="88.5.4"
12
12
  helm upgrade $NS --install prometheus-stack $OCI_CHART \
13
13
  --values prom-operator/prometheus-stack.yaml \
14
14
  --version $PROMETHEUS_STACK_CHART_VERSION $HELM_WHAT
File without changes
@@ -0,0 +1,88 @@
1
+ # triton-inference-leash
2
+
3
+ Watches for GCP zone drift between a leader pod (`triton-inference-server`) and one or more follower pods, and rolls the follower back into alignment when they diverge.
4
+
5
+ ## Why
6
+
7
+ Inter-zone egress between co-located services costs ~$50–70/day/cluster. Pod affinity rules keep them co-located at scheduling time, but if the leader reschedules to a different zone (node failure, eviction, etc.) the existing follower pods stay put. This watcher detects the drift and rolls the follower deployment so its affinity rules re-land it in the leader's zone.
8
+
9
+ ## How it works
10
+
11
+ Every 60 seconds the watcher:
12
+ 1. Fetches all node → zone mappings in a single `kubectl get nodes` call
13
+ 2. Looks up the zone of the running leader pod
14
+ 3. Looks up the zone of **every** running follower pod
15
+ 4. If any follower pod is outside the leader's zone → triggers `kubectl rollout restart` on the follower deployment
16
+ 5. The existing pod affinity on the follower reschedules all pods into the leader's zone
17
+
18
+ A 5-minute cooldown prevents back-to-back rollouts if something is thrashing.
19
+
20
+ ## Deploy
21
+
22
+ Before deploying, set `FOLLOWER_LABEL` and `FOLLOWER_DEPLOY` in the Deployment env block — both default to `REPLACE_ME_BEFORE_DEPLOYING` and the container will exit with an error if left unset.
23
+
24
+ Deploy it:
25
+
26
+ ```bash
27
+ helmup triton-inference-leash
28
+ ```
29
+
30
+ Shut it down:
31
+
32
+ ```bash
33
+ helmdn triton-inference-leash
34
+ ```
35
+
36
+ ## Debug mode
37
+
38
+ Debug mode logs the actions the watcher **would** take without actually rolling the follower. Useful for validating zone detection before going live.
39
+
40
+ To deploy with debug on, set `DEBUG: "true"` in the Deployment env block before applying:
41
+
42
+ ```yaml
43
+ - name: DEBUG
44
+ value: "true"
45
+ ```
46
+
47
+ Then watch the logs:
48
+
49
+ ```bash
50
+ kubectl logs -f -n default -l app=triton-inference-leash
51
+ ```
52
+
53
+ You should see `DRY-RUN: kubectl rollout restart ...` instead of an actual roll when a mismatch is detected. Set `DEBUG: "false"` and re-apply to go live.
54
+
55
+ ## Configuration
56
+
57
+ | Variable | Default | Description |
58
+ |---|---|---|
59
+ | `NAMESPACE` | `default` | Namespace where both services run |
60
+ | `LEADER_LABEL` | `app.kubernetes.io/instance=triton-inference-server` | Label selector for leader pods |
61
+ | `FOLLOWER_DEPLOY` | *(required)* | Helm release name of the follower service — used as the Deployment name and to derive its pod label (`app.kubernetes.io/instance=<name>`) |
62
+ | `POLL_INTERVAL` | `60` | Seconds between zone checks |
63
+ | `COOLDOWN` | `300` | Seconds to wait before triggering another rollout |
64
+ | `DEBUG` | `false` | Dry-run mode — logs actions without executing them |
65
+
66
+ ## Monitoring
67
+
68
+ ```bash
69
+ # Tail live logs
70
+ k8s log triton-inference-leash
71
+
72
+ # Check pod status
73
+ kubectl get pod -n default -l app=triton-inference-leash
74
+ ```
75
+
76
+ Log output examples:
77
+ ```
78
+ 2026-08-20T14:00:00Z OK: leader=us-central1-b follower=[us-central1-b]
79
+ 2026-08-20T15:00:00Z OK: leader=us-central1-b follower=[us-central1-b]
80
+ 2026-08-20T15:01:00Z MISMATCH: leader=us-central1-b follower=[us-central1-b us-central1-c] — rolling my-follower-service
81
+ 2026-08-20T15:03:12Z Rollout complete
82
+ ```
83
+
84
+ `OK` lines are logged once per hour in normal mode, and every poll cycle in debug mode.
85
+
86
+ ## Adapting for other clusters
87
+
88
+ Copy the manifest, fill in `FOLLOWER_LABEL` and `FOLLOWER_DEPLOY` for the target cluster, and apply. Everything else can be left at defaults.
@@ -0,0 +1,229 @@
1
+ # Watches for zone drift between a leader pod (triton-inference-server) and follower pods.
2
+ # When they land in different zones, inter-zone egress costs ~$50-70/day/cluster.
3
+ # On mismatch, rolls the follower deployment so its pod affinity re-lands it with the leader.
4
+ #
5
+ ---
6
+ apiVersion: v1
7
+ kind: ServiceAccount
8
+ metadata:
9
+ name: triton-inference-leash
10
+ namespace: default
11
+ ---
12
+ apiVersion: rbac.authorization.k8s.io/v1
13
+ kind: ClusterRole
14
+ metadata:
15
+ name: triton-inference-leash-nodes
16
+ rules:
17
+ - apiGroups: [""]
18
+ resources: ["nodes"]
19
+ verbs: ["get", "list"]
20
+ ---
21
+ apiVersion: rbac.authorization.k8s.io/v1
22
+ kind: ClusterRoleBinding
23
+ metadata:
24
+ name: triton-inference-leash-nodes
25
+ subjects:
26
+ - kind: ServiceAccount
27
+ name: triton-inference-leash
28
+ namespace: default
29
+ roleRef:
30
+ kind: ClusterRole
31
+ name: triton-inference-leash-nodes
32
+ apiGroup: rbac.authorization.k8s.io
33
+ ---
34
+ apiVersion: rbac.authorization.k8s.io/v1
35
+ kind: Role
36
+ metadata:
37
+ name: triton-inference-leash
38
+ namespace: default
39
+ rules:
40
+ - apiGroups: [""]
41
+ resources: ["pods"]
42
+ verbs: ["get", "list"]
43
+ - apiGroups: ["apps"]
44
+ resources: ["deployments"]
45
+ verbs: ["get", "list", "watch", "patch"]
46
+ - apiGroups: ["apps"]
47
+ resources: ["replicasets"]
48
+ verbs: ["get", "list", "watch"]
49
+ ---
50
+ apiVersion: rbac.authorization.k8s.io/v1
51
+ kind: RoleBinding
52
+ metadata:
53
+ name: triton-inference-leash
54
+ namespace: default
55
+ subjects:
56
+ - kind: ServiceAccount
57
+ name: triton-inference-leash
58
+ namespace: default
59
+ roleRef:
60
+ kind: Role
61
+ name: triton-inference-leash
62
+ apiGroup: rbac.authorization.k8s.io
63
+ ---
64
+ apiVersion: v1
65
+ kind: ConfigMap
66
+ metadata:
67
+ name: triton-inference-leash-script
68
+ namespace: default
69
+ data:
70
+ leash.sh: |
71
+ #!/bin/bash
72
+ set -uo pipefail
73
+
74
+ NAMESPACE="${NAMESPACE:-default}"
75
+ LEADER_LABEL="${LEADER_LABEL:-app.kubernetes.io/instance=triton-inference-server}"
76
+ # FOLLOWER_LABEL: Helm release name of the follower service.
77
+ # Used as both the Deployment name and to derive its pod selector
78
+ # (app.kubernetes.io/instance=<FOLLOWER_LABEL>). Set in the Deployment env block.
79
+ POLL_INTERVAL="${POLL_INTERVAL:-60}"
80
+ COOLDOWN="${COOLDOWN:-300}"
81
+ DEBUG="${DEBUG:-false}"
82
+
83
+ log() { echo "$(date -u '+%Y-%m-%dT%H:%M:%SZ') $*"; }
84
+ dryrun() { [ "$DEBUG" = "true" ] && log "DRY-RUN: $*" || eval "$@"; }
85
+
86
+ [ -z "${FOLLOWER_LABEL:-}" ] && { log "ERROR: FOLLOWER_LABEL is required"; exit 1; }
87
+ FOLLOWER_SELECTOR="app.kubernetes.io/instance=${FOLLOWER_LABEL}"
88
+
89
+ # nodeName -> zone for every node, fetched in a single call per poll.
90
+ # kubectl process startup is a CPU burst, so the loop keeps its call count flat
91
+ # regardless of follower replica count.
92
+ declare -A NODE_ZONE
93
+
94
+ load_node_zones() {
95
+ local name zone
96
+ NODE_ZONE=()
97
+ while read -r name zone; do
98
+ [ -n "$name" ] && NODE_ZONE[$name]=$zone
99
+ done < <(kubectl get nodes -o jsonpath='{range .items[*]}{.metadata.name}{" "}{.metadata.labels.topology\.kubernetes\.io/zone}{"\n"}{end}' 2>/dev/null)
100
+ [ "${#NODE_ZONE[@]}" -gt 0 ]
101
+ }
102
+
103
+ # Returns the node names of every running pod matching a label
104
+ get_pod_nodes() {
105
+ kubectl get pods -n "$NAMESPACE" -l "$1" \
106
+ --field-selector=status.phase=Running \
107
+ -o jsonpath='{.items[*].spec.nodeName}' 2>/dev/null
108
+ }
109
+
110
+ log "triton-inference-leash starting (poll=${POLL_INTERVAL}s cooldown=${COOLDOWN}s debug=${DEBUG})"
111
+ log " leader: ${LEADER_LABEL}"
112
+ log " follower: ${FOLLOWER_SELECTOR}"
113
+
114
+ last_rollout=0
115
+ last_ok_log=0
116
+
117
+ while true; do
118
+ sleep "$POLL_INTERVAL"
119
+
120
+ load_node_zones || {
121
+ log "SKIP: node list unavailable"
122
+ continue
123
+ }
124
+
125
+ leader_nodes=$(get_pod_nodes "$LEADER_LABEL")
126
+ leader_zone=${NODE_ZONE[${leader_nodes%% *}]:-}
127
+ [ -z "$leader_zone" ] && {
128
+ log "SKIP: leader pod not running or node lookup failed"
129
+ continue
130
+ }
131
+
132
+ follower_nodes=$(get_pod_nodes "$FOLLOWER_SELECTOR")
133
+ [ -z "$follower_nodes" ] && {
134
+ log "SKIP: follower pods not running or node lookup failed"
135
+ continue
136
+ }
137
+
138
+ # Flag mismatch if any follower pod is outside the leader's zone
139
+ mismatch=false
140
+ follower_zones=""
141
+ for node in $follower_nodes; do
142
+ zone=${NODE_ZONE[$node]:-}
143
+ [ -z "$zone" ] && continue
144
+ follower_zones="${follower_zones}${zone}"$'\n'
145
+ [ "$zone" != "$leader_zone" ] && mismatch=true
146
+ done
147
+
148
+ follower_zone_summary=$(echo "$follower_zones" | sort -u | tr '\n' ' ' | xargs)
149
+
150
+ now=$(date +%s)
151
+
152
+ if [ "$mismatch" = "false" ]; then
153
+ if [ "$DEBUG" = "true" ] || [ $((now - last_ok_log)) -ge 3600 ]; then
154
+ log "OK: leader=${leader_zone} follower=[${follower_zone_summary}]"
155
+ last_ok_log=$now
156
+ fi
157
+ continue
158
+ fi
159
+
160
+ since=$((now - last_rollout))
161
+ if [ "$since" -lt "$COOLDOWN" ]; then
162
+ log "MISMATCH (leader=${leader_zone} follower=[${follower_zone_summary}]) — cooldown ${since}s/${COOLDOWN}s, skipping"
163
+ continue
164
+ fi
165
+
166
+ log "MISMATCH: leader=${leader_zone} follower=[${follower_zone_summary}] — rolling ${FOLLOWER_LABEL}"
167
+ dryrun kubectl rollout restart deployment/"$FOLLOWER_LABEL" -n "$NAMESPACE"
168
+ last_rollout=$(date +%s)
169
+ dryrun kubectl rollout status deployment/"$FOLLOWER_LABEL" -n "$NAMESPACE" --timeout=5m \
170
+ && log "Rollout complete" \
171
+ || log "WARNING: rollout status timed out — check deployment manually"
172
+ done
173
+ ---
174
+ apiVersion: apps/v1
175
+ kind: Deployment
176
+ metadata:
177
+ name: triton-inference-leash
178
+ namespace: default
179
+ labels:
180
+ app: triton-inference-leash
181
+ spec:
182
+ replicas: 1
183
+ selector:
184
+ matchLabels:
185
+ app: triton-inference-leash
186
+ template:
187
+ metadata:
188
+ labels:
189
+ app: triton-inference-leash
190
+ spec:
191
+ serviceAccountName: triton-inference-leash
192
+ terminationGracePeriodSeconds: 10
193
+ containers:
194
+ - name: leash
195
+ image: us-docker.pkg.dev/leverege-registry/system/images/leverege-kubectl:v1.31.0
196
+ command: ["/bin/bash", "/scripts/leash.sh"]
197
+ env:
198
+ - name: NAMESPACE
199
+ value: "default"
200
+ - name: LEADER_LABEL
201
+ value: "app.kubernetes.io/instance=triton-inference-server"
202
+ # Helm release name of the follower service — e.g. pitcrew-vision-logic-server
203
+ - name: FOLLOWER_LABEL
204
+ value: "REPLACE_ME_BEFORE_DEPLOYING"
205
+ - name: POLL_INTERVAL
206
+ value: "60"
207
+ - name: COOLDOWN
208
+ value: "300"
209
+ - name: DEBUG
210
+ value: "false"
211
+ resources:
212
+ # No cpu limit on purpose. Each kubectl invocation is a sub-second
213
+ # full-core burst, so any CFS ceiling below a core clips nearly every
214
+ # period the container is awake and keeps ContainerHighThrottleRate
215
+ # firing on an otherwise idle pod. The request carries the scheduling
216
+ # weight; memory stays capped.
217
+ requests:
218
+ cpu: 50m
219
+ memory: 64Mi
220
+ limits:
221
+ memory: 128Mi
222
+ volumeMounts:
223
+ - name: scripts
224
+ mountPath: /scripts
225
+ volumes:
226
+ - name: scripts
227
+ configMap:
228
+ name: triton-inference-leash-script
229
+ defaultMode: 0755
@@ -0,0 +1,3 @@
1
+ #!/bin/bash
2
+ #
3
+ kubectl delete -f triton-inference-leash/follow-me.yaml
@@ -0,0 +1,16 @@
1
+ #!/bin/bash
2
+ #
3
+ showInstalling "Triton Inference Leash"
4
+
5
+ if grep -q "REPLACE_ME_BEFORE_DEPLOYING" triton-inference-leash/follow-me.yaml; then
6
+ echo "ERROR: follow-me.yaml still contains placeholder values — set FOLLOWER_LABEL and FOLLOWER_DEPLOY before deploying"
7
+ exit 1
8
+ fi
9
+
10
+ kubectl apply -f triton-inference-leash/follow-me.yaml $K8S_WHAT
11
+
12
+ cat<<MONITOR_LEASH
13
+
14
+ Monitor with => `color g "kubectl logs -f -n default -l app=triton-inference-leash"`
15
+
16
+ MONITOR_LEASH
package/src/k8scale.sh CHANGED
@@ -45,8 +45,10 @@ do
45
45
  RESULTS="`color g SUCCESS`"
46
46
  if [ "$DIRECTION" == "roll" ];
47
47
  then
48
+ btDeprecated "See => `color g 'k8s roll --help'`"
48
49
  kubectl rollout restart deployment $SERVICE &> $DEVNULL
49
50
  else
51
+ btDeprecated "See => `color g 'k8s scale --help'`"
50
52
  kubectl scale --replicas=$REPLICAS deployment $SERVICE &> $DEVNULL
51
53
  fi
52
54
  [ $? -eq 1 ] && RESULTS="`color r FAILURE`"
package/src/k8x.sh CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  . `build-tools --bashfun`
4
4
 
5
+ btDeprecated "See => `color g 'k8s exe --help'`"
6
+
5
7
  if [ ! -x "$(command -v fzf)" ];
6
8
  then
7
9
  cat<<NO_FZF
package/src/klog.sh CHANGED
@@ -1,11 +1,15 @@
1
1
  #!/bin/bash
2
2
 
3
+ . `build-tools --bashfun`
4
+
3
5
  overwhelm
4
6
 
5
7
  DTS="`date +\"%Y%m%d-%H%M%S\"`"
6
8
  PRETTY="pino-pretty -c -t 'HH:MM:ss.l' -i 'severity'"
7
9
  IFS="/" read APP <<< "$1"
8
10
 
11
+ btDeprecated "See => `color g 'k8s log --help'`"
12
+
9
13
  if [ ! -z "$APP" ];
10
14
  then
11
15
  NS=${2:-"default"}
@@ -11,6 +11,7 @@ import {
11
11
  parseHelmChart,
12
12
  proceed,
13
13
  shellCmd,
14
+ sleep,
14
15
  warning,
15
16
  } from './Utils.mjs'
16
17
 
@@ -29,6 +30,19 @@ without disrupting current configurations.` ) )
29
30
  program.parse( process.argv )
30
31
  const opts = program.opts()
31
32
 
33
+ // DEPRECATED
34
+ log( `
35
+ ${chalk.red.bold( '*** DEPRECATED *** the museum is closing *** DEPRECATED ***' )}
36
+
37
+ ${chalk.yellow( 'modify the package.json museum script to use chart-to-registry instead' )}
38
+
39
+ ${chalk.green.bold( ' "museum": "chart-to-registry",' )}
40
+
41
+ ` )
42
+
43
+ await sleep( 3000 )
44
+ // DEPRECATED
45
+
32
46
  const {
33
47
  chartName,
34
48
  chartVersion,
@@ -70,6 +70,7 @@ RUN npm ci --only=production --no-optional {{npmlogging}} && \
70
70
  # --------------------------------------------------------------
71
71
 
72
72
  FROM {{image}}
73
+ {{finalStagePreInstallPluginfile}}
73
74
 
74
75
  ARG BUILD_ENV="{{buildEnv}}"
75
76
 
@@ -97,7 +98,7 @@ RUN if [ "$BUILD_ENV" = "debian" ]; then \
97
98
  npm install -g {{npmVersion}} && \
98
99
  {{/if}}
99
100
  rm /bin/sh && ln -s /bin/bash /bin/sh && \
100
- mkdir -p /usr/src/app /tmp/levlog && chown node:node /usr/src/app; \
101
+ mkdir -p /usr/src/app /tmp/levlog && chown {{runuser}}:{{runuser}} /usr/src/app; \
101
102
  else \
102
103
  apk update && \
103
104
  apk add --no-cache bash curl tini vim {{apkadds}} && \
@@ -108,7 +109,7 @@ RUN if [ "$BUILD_ENV" = "debian" ]; then \
108
109
  npm install -g {{npmVersion}} && \
109
110
  {{/if}}
110
111
  rm /bin/sh && ln -s /bin/bash /bin/sh && \
111
- mkdir -p /usr/src/app /tmp/levlog && chown node:node /usr/src/app; \
112
+ mkdir -p /usr/src/app /tmp/levlog && chown {{runuser}}:{{runuser}} /usr/src/app; \
112
113
  fi
113
114
 
114
115
 
@@ -125,8 +126,8 @@ RUN rm -f ${NPM_CONFIG_USERCONFIG}
125
126
  ENTRYPOINT [ "tini", "--" ]
126
127
  WORKDIR /usr/src/app
127
128
 
128
- COPY --chown=node:node --from=intermediate /usr/src/app /usr/src/app
129
+ COPY --chown={{runuser}}:{{runuser}} --from=intermediate /usr/src/app /usr/src/app
129
130
 
130
131
  USER {{runuser}}
131
- COPY ./bashrc /home/node/.bashrc
132
- CMD [ "/bin/bash", "-c", "source /home/node/.bashrc && {{runCommand}}" ]
132
+ COPY --chown={{runuser}}:{{runuser}} ./bashrc /home/{{runuser}}/.bashrc
133
+ CMD [ "/bin/bash", "-c", "source /home/{{runuser}}/.bashrc && {{runCommand}}" ]
File without changes