@toa.io/operations 1.0.0-alpha.318 → 1.0.0-alpha.319

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/CHANGELOG.md CHANGED
@@ -3,6 +3,13 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # [1.0.0-alpha.319](https://github.com/toa-io/toa/compare/v1.0.0-alpha.318...v1.0.0-alpha.319) (2026-09-27)
7
+
8
+ ### Features
9
+
10
+ * **operations:** refuse a deploy where the cluster does not hold a secret ([beb7ccf](https://github.com/toa-io/toa/commit/beb7ccf35619494ab9ad6659963969bb4f9603c8))
11
+
12
+
6
13
  # [1.0.0-alpha.318](https://github.com/toa-io/toa/compare/v1.0.0-alpha.317...v1.0.0-alpha.318) (2026-09-23)
7
14
 
8
15
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@toa.io/operations",
3
- "version": "1.0.0-alpha.318",
3
+ "version": "1.0.0-alpha.319",
4
4
  "type": "module",
5
5
  "description": "Toa Deployment",
6
6
  "homepage": "https://toa.io",
@@ -30,9 +30,9 @@
30
30
  "dependencies": {
31
31
  "@toa.io/generic": "1.0.0-alpha.311",
32
32
  "@toa.io/kubernetes": "1.0.0-alpha.301",
33
- "@toa.io/norm": "1.0.0-alpha.318",
33
+ "@toa.io/norm": "1.0.0-alpha.319",
34
34
  "oxc-parser": "0.150.0",
35
35
  "paseto": "4.0.1"
36
36
  },
37
- "gitHead": "82895af77cf22e8529925ba97193d73ee2139297"
37
+ "gitHead": "ee4901f2bc12c33fc2098a62f2af0c29838a2c2c"
38
38
  }
package/readme.md CHANGED
@@ -60,6 +60,23 @@ declared. `evicted@production` states it for one environment.
60
60
 
61
61
  See [compositions](../documentation/compositions.md#evicted).
62
62
 
63
+ ## Secrets
64
+
65
+ A value that must not be in the chart is rendered as a reference to a Kubernetes secret, and the
66
+ secret is the application's to deploy. A deploy reads the namespace it deploys into before it builds
67
+ anything, and refuses where a key a workload would read is not there:
68
+
69
+ ```
70
+ $ toa deploy production
71
+ Secrets are not deployed: toa-mongodb.default/username, toa-mongodb.default/password
72
+ ```
73
+
74
+ Required is what `toa export secrets` lists, less the keys it marks `(optional)`, plus the
75
+ `registry.credentials` secret where a context names one. `toa deploy --dry` reads no cluster, and
76
+ neither does anything else that reads a context: `toa env`, `toa export`, `toa build` and `toa push`.
77
+
78
+ See [variables and secrets](../documentation/deployment.md#variables-and-secrets).
79
+
63
80
  ## Context
64
81
 
65
82
  ### Container Registry
@@ -203,6 +220,9 @@ registry:
203
220
  credentials: docker-credentials-secret-name
204
221
  ```
205
222
 
223
+ The secret itself is not Toa's to create, and a deploy that does not find it in the cluster is
224
+ [refused](#secrets).
225
+
206
226
  #### Toa images
207
227
 
208
228
  Each release publishes `ghcr.io/toa-io/runtime:<version>` and, beside it, `:alpha` or `:latest`.
@@ -6,6 +6,7 @@ import { shortcuts } from '@toa.io/norm'
6
6
 
7
7
  import { merge, declare, describe } from './.deployment/index.js'
8
8
  import { drain } from './drain.js'
9
+ import * as secrets from './secrets.js'
9
10
 
10
11
  export class Deployment {
11
12
  #chart
@@ -47,6 +48,19 @@ export class Deployment {
47
48
  this.#target = target
48
49
  }
49
50
 
51
+ /**
52
+ * Refuses a deploy the cluster is not ready for: a secret a workload reads that is not there
53
+ * leaves its pod uncreated, which is seen only once every image has been built and pushed.
54
+ *
55
+ * @param {toa.deployment.installation.Options} options
56
+ * @returns {Promise<void>}
57
+ */
58
+ async verify(options) {
59
+ const references = secrets.references(this.variables(), this.#values.credentials)
60
+
61
+ await secrets.verify(this.#process, references, options)
62
+ }
63
+
50
64
  async install(options) {
51
65
  if (options.target) this.#target = options.target
52
66
  if (this.#target === undefined) throw new Error("Deployment hasn't been exported")
@@ -40,6 +40,9 @@ export class Operator {
40
40
  async install(options = {}) {
41
41
  options = Object.assign({}, OPTIONS, options)
42
42
 
43
+ // nothing is built, pushed or applied where the cluster cannot run what this deploys
44
+ await this.#deployment.verify(options)
45
+
43
46
  await Promise.all([this.export(), this.push()])
44
47
  await this.#registry.alias(this.#environment)
45
48
  await this.#deployment.install(options)
@@ -54,6 +54,7 @@ describe('install', () => {
54
54
  beforeEach(() => {
55
55
  deployment = /** @type {toa.deployment.Deployment} */ {
56
56
  export: mock.fn(async () => {}),
57
+ verify: mock.fn(async () => {}),
57
58
  install: mock.fn(async () => {})
58
59
  }
59
60
  registry = /** @type {toa.deployment.Registry} */ {
@@ -62,6 +63,46 @@ describe('install', () => {
62
63
  }
63
64
  })
64
65
 
66
+ it('should verify the secrets before anything is built', async () => {
67
+ const order = []
68
+
69
+ deployment.verify = mock.fn(async () => {
70
+ order.push('verify')
71
+ })
72
+ deployment.export = mock.fn(async () => {
73
+ order.push('export')
74
+ })
75
+ registry.push = mock.fn(async () => {
76
+ order.push('push')
77
+ })
78
+
79
+ operator = new Operator(deployment, registry, 'production')
80
+
81
+ await operator.install({ namespace: 'acme' })
82
+
83
+ // export and push race each other, and both wait for the cluster to have been read
84
+ assert.strictEqual(order[0], 'verify')
85
+ assert.deepStrictEqual(order.slice(1).sort(), ['export', 'push'])
86
+ assert.deepStrictEqual(deployment.verify.mock.calls[0].arguments, [
87
+ { wait: false, namespace: 'acme' }
88
+ ])
89
+ })
90
+
91
+ it('should build nothing where the secrets are not deployed', async () => {
92
+ deployment.verify = mock.fn(async () => {
93
+ throw new Error('Secrets are not deployed: toa-mongodb.default/username')
94
+ })
95
+
96
+ operator = new Operator(deployment, registry, 'production')
97
+
98
+ await assert.rejects(operator.install(), /Secrets are not deployed/)
99
+
100
+ assert.strictEqual(deployment.export.mock.callCount(), 0)
101
+ assert.strictEqual(registry.push.mock.callCount(), 0)
102
+ assert.strictEqual(registry.alias.mock.callCount(), 0)
103
+ assert.strictEqual(deployment.install.mock.callCount(), 0)
104
+ })
105
+
65
106
  it('should tag the environment after a push', async () => {
66
107
  const order = []
67
108
 
@@ -0,0 +1,92 @@
1
+ /**
2
+ * What the cluster has to hold for a deployment to run, and the refusal where it does not.
3
+ *
4
+ * A pod whose secret is not there is never created, and nothing the chart carries says so: Helm
5
+ * reports a release, the rollout stalls, and the name of what is missing is in the events of a pod
6
+ * that does not exist. The cluster answers the question before any of that, for one call.
7
+ */
8
+
9
+ /**
10
+ * Every secret the workloads read, as `<name>/<key>`, and the image pull secret by name alone —
11
+ * what it holds is the registry's affair, and its absence stops a pod just the same.
12
+ *
13
+ * @param {toa.deployment.dependency.Variable[]} variables
14
+ * @param {string} [credentials] `registry.credentials`
15
+ * @returns {string[]}
16
+ */
17
+ export function references(variables, credentials) {
18
+ /** @type {string[]} */
19
+ const references = []
20
+
21
+ // as the chart renders it: `imagePullSecrets` is there where the name is, and a context
22
+ // that states the key and no name names no secret
23
+ if (credentials) references.push(credentials)
24
+
25
+ for (const { secret } of variables) {
26
+ // a key the workload starts without is not one the cluster has to hold
27
+ if (secret === undefined || secret.optional === true) continue
28
+
29
+ const reference = `${secret.name}/${secret.key}`
30
+
31
+ if (!references.includes(reference)) references.push(reference)
32
+ }
33
+
34
+ return references
35
+ }
36
+
37
+ /**
38
+ * @param {toa.operations.Process} process
39
+ * @param {string[]} references
40
+ * @param {toa.deployment.installation.Options} options
41
+ * @returns {Promise<void>}
42
+ */
43
+ export async function verify(process, references, options) {
44
+ if (references.length === 0) return
45
+
46
+ const deployed = await read(process, options)
47
+ const missing = references.filter((reference) => !deployed.has(reference))
48
+
49
+ if (missing.length > 0)
50
+ throw new Error(`Secrets are not deployed: ${missing.join(', ')}`)
51
+ }
52
+
53
+ /**
54
+ * What the namespace holds, as the names of its secrets and the `<name>/<key>` of every key in
55
+ * them. The namespace is read whole: a call per secret would be a process per secret, and the
56
+ * wrapper for a single one cannot tell a secret that is absent from a cluster that cannot be
57
+ * reached — which a preflight is the worst place to guess at. Listing asks no permission a
58
+ * deploy does not already have, Helm keeping its own release state in secrets of this namespace.
59
+ *
60
+ * Only the keys are read. What is under them is the cluster's and stays there.
61
+ *
62
+ * @param {toa.operations.Process} process
63
+ * @param {toa.deployment.installation.Options} options
64
+ * @returns {Promise<Set<string>>}
65
+ */
66
+ async function read(process, options) {
67
+ const args = ['get', 'secrets', '-o', TEMPLATE]
68
+
69
+ if (options.namespace !== undefined) args.push('-n', options.namespace)
70
+
71
+ const output = await process.execute('kubectl', args, { silently: true })
72
+
73
+ /** @type {Set<string>} */
74
+ const deployed = new Set()
75
+
76
+ for (const line of output.split('\n')) {
77
+ if (line === '') continue
78
+
79
+ const [name, ...keys] = line.split(' ')
80
+
81
+ deployed.add(name)
82
+
83
+ for (const key of keys) deployed.add(`${name}/${key}`)
84
+ }
85
+
86
+ return deployed
87
+ }
88
+
89
+ /** A secret's name and the keys in it, one secret a line. Neither may hold a space. */
90
+ const TEMPLATE =
91
+ 'go-template={{range .items}}{{.metadata.name}}' +
92
+ '{{range $key, $value := .data}} {{$key}}{{end}}{{"\\n"}}{{end}}'
@@ -0,0 +1,172 @@
1
+ import { it, describe, beforeEach, mock } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { references, verify } from './secrets.js'
5
+
6
+ describe('references', () => {
7
+ it('should reference a secret by name and key', () => {
8
+ const variables = [
9
+ { name: 'TOA_CONTEXT', value: 'todos' },
10
+ { name: 'USERNAME', secret: { name: 'toa-mongodb.default', key: 'username' } },
11
+ { name: 'PASSWORD', secret: { name: 'toa-mongodb.default', key: 'password' } }
12
+ ]
13
+
14
+ assert.deepStrictEqual(references(variables), [
15
+ 'toa-mongodb.default/username',
16
+ 'toa-mongodb.default/password'
17
+ ])
18
+ })
19
+
20
+ it('should not reference an optional key', () => {
21
+ const variables = [
22
+ { name: 'KEY', secret: { name: 'toa-tokens', key: 'key0', optional: true } },
23
+ { name: 'USERNAME', secret: { name: 'toa-mongodb.default', key: 'username' } }
24
+ ]
25
+
26
+ assert.deepStrictEqual(references(variables), ['toa-mongodb.default/username'])
27
+ })
28
+
29
+ it('should reference a key read by two workloads once', () => {
30
+ const secret = { name: 'toa-mongodb.default', key: 'username' }
31
+
32
+ assert.deepStrictEqual(references([{ name: 'A', secret }, { name: 'B', secret }]), [
33
+ 'toa-mongodb.default/username'
34
+ ])
35
+ })
36
+
37
+ it('should reference the image pull secret by name', () => {
38
+ const variables = [
39
+ { name: 'USERNAME', secret: { name: 'toa-mongodb.default', key: 'username' } }
40
+ ]
41
+
42
+ assert.deepStrictEqual(references(variables, 'registry-credentials'), [
43
+ 'registry-credentials',
44
+ 'toa-mongodb.default/username'
45
+ ])
46
+ })
47
+
48
+ it('should reference nothing where nothing is secret', () => {
49
+ assert.deepStrictEqual(references([{ name: 'TOA_ENV', value: 'production' }]), [])
50
+ })
51
+
52
+ it('should reference no pull secret where a context names none', () => {
53
+ // as the chart reads it: `credentials: ~` renders no `imagePullSecrets`
54
+ assert.deepStrictEqual(references([], null), [])
55
+ assert.deepStrictEqual(references([], ''), [])
56
+ })
57
+ })
58
+
59
+ describe('verify', () => {
60
+ /** @type {string} what `kubectl` answers */
61
+ let secrets
62
+
63
+ /** @type {toa.operations.Process} */
64
+ let process
65
+
66
+ beforeEach(() => {
67
+ secrets = ''
68
+ process = /** @type {toa.operations.Process} */ {
69
+ execute: mock.fn(async () => secrets)
70
+ }
71
+ })
72
+
73
+ it('should read the namespace deployed into', async () => {
74
+ secrets = 'toa-mongodb.default password username'
75
+
76
+ await verify(process, ['toa-mongodb.default/username'], { namespace: 'acme' })
77
+
78
+ const [cmd, args, options] = process.execute.mock.calls[0].arguments
79
+
80
+ assert.strictEqual(cmd, 'kubectl')
81
+ assert.deepStrictEqual(args.slice(0, 2), ['get', 'secrets'])
82
+ assert.deepStrictEqual(args.slice(-2), ['-n', 'acme'])
83
+ assert.deepStrictEqual(options, { silently: true })
84
+ })
85
+
86
+ it('should read the current namespace where none is given', async () => {
87
+ await verify(process, [], {})
88
+
89
+ assert.strictEqual(process.execute.mock.callCount(), 0)
90
+
91
+ await verify(process, ['toa-mongodb.default/username'], {}).catch(() => {})
92
+
93
+ const [, args] = process.execute.mock.calls[0].arguments
94
+
95
+ assert.strictEqual(args.includes('-n'), false)
96
+ })
97
+
98
+ it('should name every key that is not deployed', async () => {
99
+ secrets = 'toa-amqp-context.default password username'
100
+
101
+ await assert.rejects(
102
+ verify(
103
+ process,
104
+ [
105
+ 'toa-amqp-context.default/username',
106
+ 'toa-mongodb.default/username',
107
+ 'toa-mongodb.default/password'
108
+ ],
109
+ {}
110
+ ),
111
+ {
112
+ message:
113
+ 'Secrets are not deployed: toa-mongodb.default/username, ' +
114
+ 'toa-mongodb.default/password'
115
+ }
116
+ )
117
+ })
118
+
119
+ it('should name the key a deployed secret lacks', async () => {
120
+ secrets = 'toa-mongodb.default username'
121
+
122
+ await assert.rejects(
123
+ verify(process, ['toa-mongodb.default/username', 'toa-mongodb.default/password'], {}),
124
+ { message: 'Secrets are not deployed: toa-mongodb.default/password' }
125
+ )
126
+ })
127
+
128
+ it('should name a secret referenced by name alone', async () => {
129
+ secrets = 'toa-mongodb.default password username'
130
+
131
+ await assert.rejects(verify(process, ['registry-credentials'], {}), {
132
+ message: 'Secrets are not deployed: registry-credentials'
133
+ })
134
+
135
+ secrets = 'registry-credentials .dockerconfigjson'
136
+
137
+ await verify(process, ['registry-credentials'], {})
138
+ })
139
+
140
+ it('should pass where the cluster holds every key', async () => {
141
+ secrets =
142
+ 'default-token\ntoa-amqp-context.default password username\n' +
143
+ 'toa-mongodb.default password username'
144
+
145
+ await verify(
146
+ process,
147
+ [
148
+ 'toa-amqp-context.default/username',
149
+ 'toa-amqp-context.default/password',
150
+ 'toa-mongodb.default/username',
151
+ 'toa-mongodb.default/password'
152
+ ],
153
+ {}
154
+ )
155
+ })
156
+
157
+ it('should not read the cluster where nothing is secret', async () => {
158
+ await verify(process, [], { namespace: 'acme' })
159
+
160
+ assert.strictEqual(process.execute.mock.callCount(), 0)
161
+ })
162
+
163
+ it('should fail as the error it is where the cluster cannot be read', async () => {
164
+ process.execute = mock.fn(async () => {
165
+ throw new Error('Command failed with exit code 1: kubectl get secrets')
166
+ })
167
+
168
+ await assert.rejects(verify(process, ['toa-mongodb.default/username'], {}), {
169
+ message: /^Command failed/
170
+ })
171
+ })
172
+ })
@@ -44,6 +44,8 @@ declare namespace toa.deployment {
44
44
  secret?: {
45
45
  name: string
46
46
  key: string
47
+ /** The workload starts without it, so the cluster need not hold it. */
48
+ optional?: boolean
47
49
  }
48
50
  }
49
51
 
@@ -45,6 +45,9 @@ declare namespace toa.deployment {
45
45
  interface Deployment {
46
46
  export(target: string): Promise<void>
47
47
 
48
+ /** Raises where the cluster does not hold a secret the workloads read. */
49
+ verify(options: installation.Options): Promise<void>
50
+
48
51
  install(options: installation.Options): Promise<void>
49
52
 
50
53
  template(options: template.Options): Promise<string>