@toa.io/operations 1.0.0-alpha.317 → 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,20 @@
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
+
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)
14
+
15
+ ### Features
16
+
17
+ * **deployment:** copy a component's linked sources as files ([1a1a8c1](https://github.com/toa-io/toa/commit/1a1a8c10684348dba02b475ab809a4083f86a601))
18
+
19
+
6
20
  # [1.0.0-alpha.317](https://github.com/toa-io/toa/compare/v1.0.0-alpha.316...v1.0.0-alpha.317) (2026-09-23)
7
21
 
8
22
  **Note:** Version bump only for package @toa.io/operations
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@toa.io/operations",
3
- "version": "1.0.0-alpha.317",
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.317",
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": "a88d6124557d4e2819402ae8a0b891fb3dee479c"
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
@@ -96,6 +113,11 @@ for what the components declare — and is built only when one of those changes.
96
113
  layer, so a deploy that changes code alone builds and pushes that layer, and neither
97
114
  downloads nor uploads the dependencies again. The same holds for `mono`.
98
115
 
116
+ The sources are copied as files, so a component that links a directory in — sources it shares with
117
+ another component — carries what the link points to, and a link the build cannot read fails the
118
+ build. What is linked is source alone: dependencies are installed per component, from its own
119
+ `package.json`.
120
+
99
121
  Beside each component's sources the layer carries `manifest.toa.json`, the manifest as this
100
122
  build normalised it. A composition reads it instead of normalising again, so it loads no
101
123
  bridge to read what a module declares; a workspace has no such file and is read as it always
@@ -198,6 +220,9 @@ registry:
198
220
  credentials: docker-credentials-secret-name
199
221
  ```
200
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
+
201
226
  #### Toa images
202
227
 
203
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")
@@ -75,8 +75,9 @@ export class Bundle extends Image {
75
75
  for (const component of this.components) {
76
76
  const target = join(context, component.locator.label)
77
77
 
78
- // what was installed in the workspace is not what the image installs
79
- await cp(component.path, target, { recursive: true, filter: sources })
78
+ // what was installed in the workspace is not what the image installs; a link is copied as
79
+ // the files it points to, which is the only way what it points to reaches the image at all
80
+ await cp(component.path, target, { recursive: true, filter: sources, dereference: true })
80
81
  await declare(component.path, target, component.locator.label)
81
82
  await normalized(component, target)
82
83
  }
@@ -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>