hammerkit 1.9.2 → 1.11.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.
Files changed (44) hide show
  1. package/build.schema.json +52 -1
  2. package/dist/cache/backends/registry/registry-client.js +19 -4
  3. package/dist/cli.js +1 -0
  4. package/dist/docker/pull.js +38 -1
  5. package/dist/environment/replace-env-variables.js +53 -22
  6. package/dist/executer/container-secrets.js +59 -0
  7. package/dist/executer/deadline.js +29 -0
  8. package/dist/executer/docker-service.js +58 -38
  9. package/dist/executer/docker-task.js +37 -23
  10. package/dist/executer/execute-init-task.js +100 -0
  11. package/dist/executer/execute-work-service.js +30 -1
  12. package/dist/executer/execute-work-task.js +4 -25
  13. package/dist/executer/execute-work-tree.js +28 -2
  14. package/dist/executer/get-container-binds.js +8 -4
  15. package/dist/executer/local-task.js +6 -1
  16. package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +13 -7
  17. package/dist/kubernetes/secrets.js +83 -0
  18. package/dist/kubernetes/volumes.js +1 -1
  19. package/dist/optimizer/work-task-cache-description.js +6 -0
  20. package/dist/parse-set-arguments.js +38 -0
  21. package/dist/planner/service-definition.js +40 -0
  22. package/dist/planner/utils/append-work-dependencies.js +2 -0
  23. package/dist/planner/utils/append-work-needs.js +7 -0
  24. package/dist/planner/utils/append-work-service.js +67 -1
  25. package/dist/planner/utils/append-work-task.js +6 -0
  26. package/dist/planner/utils/parse-work-generate.js +4 -2
  27. package/dist/planner/utils/parse-work-mount.js +7 -3
  28. package/dist/planner/utils/parse-work-volume.js +8 -3
  29. package/dist/planner/utils/plan-work-volume.js +1 -0
  30. package/dist/planner/utils/service-hostname.js +15 -0
  31. package/dist/planner/utils/template-value.js +1 -1
  32. package/dist/planner/work-item-status.js +3 -1
  33. package/dist/planner/work-runtime-docker.js +8 -0
  34. package/dist/planner/work-runtime-kubernetes.js +40 -6
  35. package/dist/planner/work-secret.js +83 -0
  36. package/dist/program.js +9 -1
  37. package/dist/schema/build-file-base-task-schema.js +2 -0
  38. package/dist/schema/build-file-container-service-schema.js +5 -0
  39. package/dist/schema/build-file-secret-schema.js +22 -0
  40. package/dist/schema/build-file-volume-schema.js +3 -1
  41. package/dist/schema/reference-parser.js +14 -0
  42. package/dist/schema/work-scope-parser.js +30 -5
  43. package/dist/utils/redact.js +29 -0
  44. package/package.json +1 -1
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.getHealthcheckTimeout = exports.DEFAULT_HEALTHCHECK_TIMEOUT = exports.appendWorkService = void 0;
3
+ exports.getHealthcheckTimeout = exports.DEFAULT_HEALTHCHECK_TIMEOUT = exports.getInitTimeout = exports.DEFAULT_INIT_TIMEOUT = exports.getPlannedService = exports.appendWorkService = void 0;
4
+ const work_secret_1 = require("../work-secret");
4
5
  const work_service_id_1 = require("../work-service-id");
5
6
  const parse_work_ports_1 = require("./parse-work-ports");
6
7
  const template_value_1 = require("./template-value");
@@ -12,6 +13,8 @@ const parse_work_mounts_1 = require("./parse-work-mounts");
12
13
  const parse_work_source_1 = require("./parse-work-source");
13
14
  const append_work_dependencies_1 = require("./append-work-dependencies");
14
15
  const append_work_needs_1 = require("./append-work-needs");
16
+ const work_item_1 = require("../work-item");
17
+ const append_work_task_1 = require("./append-work-task");
15
18
  const replace_env_variables_1 = require("../../environment/replace-env-variables");
16
19
  const state_1 = require("../../executer/state");
17
20
  const lazy_resolver_1 = require("../../executer/lazy-resolver");
@@ -42,6 +45,7 @@ function appendWorkService(workTree, service, environment, context) {
42
45
  workTree.services[workItem.name] = workStateItem;
43
46
  (0, append_work_dependencies_1.appendWorkDependencies)(workTree, service, workStateItem, environment, context);
44
47
  (0, append_work_needs_1.appendWorkNeeds)(workTree, service, workStateItem, environment, context);
48
+ appendServiceInit(workTree, service, workStateItem, environment, context);
45
49
  return workStateItem;
46
50
  }
47
51
  else {
@@ -107,9 +111,71 @@ function parseService(workTree, service, environment, context) {
107
111
  mounts: (0, parse_work_mounts_1.parseWorkMounts)(service.cwd, service.schema, envs),
108
112
  src: (0, parse_work_source_1.parseWorkSource)(service.cwd, service.schema.src, envs),
109
113
  caching,
114
+ // set once the service is planned, see appendServiceInit
115
+ init: null,
116
+ secrets: (0, work_secret_1.parseWorkSecrets)(service.cwd, context.projectRoot, service.schema.secrets, envs, environment),
110
117
  };
111
118
  }
112
119
  }
120
+ // The task named by `init` is planned like any other task. It needs the
121
+ // service under the service's name, but through a view of it that turns running
122
+ // once the healthcheck passed; the service itself only runs for everything else
123
+ // once the init succeeded. The init never makes the service start (it is not
124
+ // among the service's requirers), never comes from the cache, and gets the init
125
+ // timeout unless it declares its own.
126
+ function appendServiceInit(workTree, service, item, environment, context) {
127
+ if (!service.init || !(0, work_item_1.isContainerWorkServiceItem)(item)) {
128
+ return;
129
+ }
130
+ const task = (0, append_work_task_1.appendWorkTask)(workTree, service.init.cwd, service.init.task, environment, context);
131
+ const state = new state_1.State({ type: 'pending', stateKey: null });
132
+ const view = { ...item, state };
133
+ initViews.set(view, item);
134
+ const selfNeed = task.needs.findIndex((need) => need.service.name === item.name);
135
+ if (selfNeed >= 0) {
136
+ task.needs[selfNeed] = { name: task.needs[selfNeed].name, service: view };
137
+ }
138
+ else {
139
+ task.needs.push({ name: item.name, service: view });
140
+ }
141
+ removeRequirer(item, task.name);
142
+ task.data.caching = { ...task.data.caching, method: 'none' };
143
+ if (task.data.timeout === null) {
144
+ task.data.timeout = getInitTimeout(undefined, environment);
145
+ }
146
+ item.data.init = { task, state };
147
+ }
148
+ const initViews = new WeakMap();
149
+ // The service itself for the view its init task needs, else the item.
150
+ function getPlannedService(item) {
151
+ return initViews.get(item) ?? item;
152
+ }
153
+ exports.getPlannedService = getPlannedService;
154
+ function removeRequirer(item, name) {
155
+ const index = item.requiredBy.findIndex((requirer) => requirer.name === name);
156
+ if (index >= 0) {
157
+ item.requiredBy.splice(index, 1);
158
+ }
159
+ }
160
+ exports.DEFAULT_INIT_TIMEOUT = '5m';
161
+ // The init task's own `timeout`, else HAMMERKIT_INIT_TIMEOUT, else 5 minutes:
162
+ // an init that never ends would otherwise keep the run waiting.
163
+ function getInitTimeout(timeout, environment) {
164
+ if (timeout) {
165
+ return (0, units_1.parseDuration)(timeout);
166
+ }
167
+ const fromEnv = environment.processEnvs.HAMMERKIT_INIT_TIMEOUT;
168
+ if (fromEnv) {
169
+ try {
170
+ return (0, units_1.parseDuration)(fromEnv);
171
+ }
172
+ catch (e) {
173
+ throw new Error(`HAMMERKIT_INIT_TIMEOUT: ${(0, log_1.getErrorMessage)(e)}`);
174
+ }
175
+ }
176
+ return (0, units_1.parseDuration)(exports.DEFAULT_INIT_TIMEOUT);
177
+ }
178
+ exports.getInitTimeout = getInitTimeout;
113
179
  exports.DEFAULT_HEALTHCHECK_TIMEOUT = '20s';
114
180
  // A service's own `healthcheck.timeout`, else HAMMERKIT_HEALTHCHECK_TIMEOUT,
115
181
  // else 20s: a check that never passes fails the run instead of hanging it.
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.appendWorkTask = void 0;
4
+ const work_secret_1 = require("../work-secret");
4
5
  const parse_work_generate_1 = require("./parse-work-generate");
5
6
  const template_value_1 = require("./template-value");
6
7
  const parse_work_mounts_1 = require("./parse-work-mounts");
@@ -67,6 +68,7 @@ function parseTask(cwd, task, environment, context) {
67
68
  shell: task.schema.shell ? (0, template_value_1.templateValue)(task.schema.shell, envs) : '/bin/sh',
68
69
  continuous: task.schema.continuous ?? false,
69
70
  timeout: task.schema.timeout ? (0, units_1.parseDuration)(task.schema.timeout) : null,
71
+ secrets: (0, work_secret_1.parseWorkSecrets)(cwd, context.projectRoot, task.schema.secrets, envs, environment),
70
72
  };
71
73
  if ((0, build_file_task_schema_1.isBuildFileContainerTaskSchema)(task.schema)) {
72
74
  return {
@@ -78,6 +80,10 @@ function parseTask(cwd, task, environment, context) {
78
80
  };
79
81
  }
80
82
  else {
83
+ const fileSecret = baseWorkNode.secrets.find((secret) => secret.target.type === 'file');
84
+ if (fileSecret) {
85
+ throw new Error(`task ${task.relativeName}: a local task takes secrets as env only (${(0, work_secret_1.getSecretName)(fileSecret)})`);
86
+ }
81
87
  return {
82
88
  ...baseWorkNode,
83
89
  type: 'local-task',
@@ -16,6 +16,7 @@ function parseWorkGenerate(cwd, schema, envs) {
16
16
  volumeName: (0, plan_work_volume_1.getVolumeName)(filePath),
17
17
  resetOnChange: generate.resetOnChange,
18
18
  export: generate.export,
19
+ exportAlways: generate.exportAlways,
19
20
  isFile: (0, path_1.extname)(generate.path).length > 1,
20
21
  inherited: null,
21
22
  };
@@ -24,13 +25,14 @@ function parseWorkGenerate(cwd, schema, envs) {
24
25
  exports.parseWorkGenerate = parseWorkGenerate;
25
26
  function mapGenerate(generate) {
26
27
  if (typeof generate === 'string') {
27
- return { path: generate, resetOnChange: true, export: false };
28
+ return { path: generate, resetOnChange: true, export: false, exportAlways: false };
28
29
  }
29
30
  else {
30
31
  return {
31
32
  path: generate.path,
32
33
  resetOnChange: generate.resetOnChange ?? true,
33
- export: generate.export ?? false,
34
+ export: !!generate.export,
35
+ exportAlways: generate.export === 'always',
34
36
  };
35
37
  }
36
38
  }
@@ -7,22 +7,26 @@ const path_1 = require("path");
7
7
  function parseWorkMount(cwd, dir) {
8
8
  const parts = dir.split(':');
9
9
  if (parts.length === 1) {
10
- return parseLocalMount(cwd, dir, dir, dir);
10
+ return parseLocalMount(cwd, dir, dir, dir, false);
11
11
  }
12
12
  else if (parts.length === 2) {
13
- return parseLocalMount(cwd, parts[0], parts[1], dir);
13
+ return parseLocalMount(cwd, parts[0], parts[1], dir, false);
14
+ }
15
+ else if (parts.length === 3 && (parts[2] === 'ro' || parts[2] === 'rw')) {
16
+ return parseLocalMount(cwd, parts[0], parts[1], dir, parts[2] === 'ro');
14
17
  }
15
18
  else {
16
19
  throw new Error(`invalid mount ${dir}`);
17
20
  }
18
21
  }
19
22
  exports.parseWorkMount = parseWorkMount;
20
- function parseLocalMount(cwd, localPath, containerPath, mount) {
23
+ function parseLocalMount(cwd, localPath, containerPath, mount, readOnly) {
21
24
  return {
22
25
  localPath: (0, normalize_path_1.normalizePath)(cwd, (0, os_1.homedir)(), localPath),
23
26
  containerPath: (0, normalize_path_1.normalizePath)(cwd, cwd, containerPath),
24
27
  isFile: (0, path_1.extname)(localPath).length > 1,
25
28
  mount,
29
+ readOnly,
26
30
  };
27
31
  }
28
32
  //# sourceMappingURL=parse-work-mount.js.map
@@ -8,7 +8,10 @@ function parseWorkVolume(cwd, volume, envs) {
8
8
  if (typeof volume === 'string') {
9
9
  const parts = volume.split(':');
10
10
  if (parts.length === 2) {
11
- return parseVolume(cwd, parts[0], parts[1]);
11
+ return parseVolume(cwd, parts[0], parts[1], false);
12
+ }
13
+ else if (parts.length === 3 && (parts[2] === 'ro' || parts[2] === 'rw')) {
14
+ return parseVolume(cwd, parts[0], parts[1], parts[2] === 'ro');
12
15
  }
13
16
  else {
14
17
  throw new Error(`invalid volume ${volume}`);
@@ -18,10 +21,11 @@ function parseWorkVolume(cwd, volume, envs) {
18
21
  const path = (0, template_value_1.templateValue)(volume.path, envs);
19
22
  return {
20
23
  containerPath: path,
21
- export: volume.export ?? false,
24
+ export: !!volume.export,
22
25
  name: volume.name ? (0, template_value_1.templateValue)(volume.name, envs) : (0, plan_work_volume_1.getVolumeName)(path),
23
26
  inherited: null,
24
27
  resetOnChange: volume.resetOnChange ?? false,
28
+ readOnly: volume.readOnly ?? false,
25
29
  };
26
30
  }
27
31
  }
@@ -33,13 +37,14 @@ function parseWorkVolumes(cwd, volumes, envs) {
33
37
  return volumes.map((v) => parseWorkVolume(cwd, v, envs));
34
38
  }
35
39
  exports.parseWorkVolumes = parseWorkVolumes;
36
- function parseVolume(cwd, name, containerPath) {
40
+ function parseVolume(cwd, name, containerPath, readOnly) {
37
41
  return {
38
42
  name,
39
43
  resetOnChange: false,
40
44
  containerPath: (0, normalize_path_1.normalizePath)(cwd, cwd, containerPath),
41
45
  inherited: null,
42
46
  export: false,
47
+ readOnly,
43
48
  };
44
49
  }
45
50
  //# sourceMappingURL=parse-work-volume.js.map
@@ -24,6 +24,7 @@ function getContainerVolumes(task, mounts) {
24
24
  containerPath: generate.path,
25
25
  inherited: generate.inherited,
26
26
  export: generate.export,
27
+ readOnly: false,
27
28
  });
28
29
  }
29
30
  return volumes;
@@ -0,0 +1,15 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getServiceHostname = void 0;
4
+ // A DNS label from the service name: Docker writes the container's hostname
5
+ // into its own /etc/hosts, so the service reaches itself by name (a database
6
+ // that advertises its own address, such as a MongoDB replica set member).
7
+ function getServiceHostname(name) {
8
+ return (name
9
+ .toLowerCase()
10
+ .replace(/[^a-z0-9-]+/g, '-')
11
+ .replace(/^-+|-+$/g, '')
12
+ .substring(0, 63) || 'service');
13
+ }
14
+ exports.getServiceHostname = getServiceHostname;
15
+ //# sourceMappingURL=service-hostname.js.map
@@ -7,7 +7,7 @@ function templateValue(value, envs) {
7
7
  }
8
8
  let result = value;
9
9
  for (const [key, envValue] of Object.entries(envs.variables)) {
10
- result = result.replace(new RegExp(`\\$${key}`, 'gi'), envValue);
10
+ result = result.replace(new RegExp(`\\$\\{${key}\\}|\\$${key}`, 'gi'), envValue);
11
11
  }
12
12
  return result;
13
13
  }
@@ -5,6 +5,7 @@ const emitter_1 = require("../utils/emitter");
5
5
  const environment_config_1 = require("../utils/environment-config");
6
6
  const log_1 = require("../log");
7
7
  const buffer_context_1 = require("../utils/buffer-context");
8
+ const redact_1 = require("../utils/redact");
8
9
  const StatusBufferMax = (0, environment_config_1.getEnvironmentConfig)('STATUS_BUFFER_LIMIT', 300);
9
10
  const ConsoleBufferMax = (0, environment_config_1.getEnvironmentConfig)('CONSOLE_BUFFER_LIMIT', 30);
10
11
  function logContext(type, item) {
@@ -18,7 +19,8 @@ function statusConsole(writable) {
18
19
  const statusBuffer = new buffer_context_1.BufferContext(StatusBufferMax);
19
20
  const consoleBuffer = new buffer_context_1.BufferContext(ConsoleBufferMax);
20
21
  const emit = (0, emitter_1.emitter)();
21
- function addMessage(context, message) {
22
+ function addMessage(context, unredacted) {
23
+ const message = { ...unredacted, message: (0, redact_1.redact)(unredacted.message) };
22
24
  emit.emit(message);
23
25
  writable.write(JSON.stringify({ context, message }) + '\n');
24
26
  if (message.type === 'console') {
@@ -6,6 +6,7 @@ const execute_docker_1 = require("../executer/execute-docker");
6
6
  const remove_container_1 = require("../docker/remove-container");
7
7
  const docker_task_1 = require("../executer/docker-task");
8
8
  const docker_service_1 = require("../executer/docker-service");
9
+ const container_secrets_1 = require("../executer/container-secrets");
9
10
  const using_container_1 = require("../docker/using-container");
10
11
  const event_cache_1 = require("../executer/event-cache");
11
12
  const get_docker_executor_1 = require("../executer/get-docker-executor");
@@ -14,6 +15,7 @@ const tar_1 = require("tar");
14
15
  const plan_work_volume_1 = require("./utils/plan-work-volume");
15
16
  const work_instance_id_1 = require("./work-instance-id");
16
17
  const stream_1 = require("stream");
18
+ const service_definition_1 = require("./service-definition");
17
19
  const container_task_state_1 = require("../executer/container-task-state");
18
20
  function dockerTaskRuntime(task, workEnvironment) {
19
21
  const docker = (0, execute_docker_1.getContainerCli)(workEnvironment);
@@ -160,6 +162,11 @@ function dockerServiceRuntime(service, workEnvironment) {
160
162
  if (!currentService) {
161
163
  return;
162
164
  }
165
+ if (currentService.Labels['hammerkit-definition'] !== (0, service_definition_1.getServiceDefinitionHash)(service)) {
166
+ service.status.write('info', `${service.name} changed since it was started, recreating it`);
167
+ await (0, remove_container_1.removeContainer)(docker.getContainer(currentService.Id));
168
+ return;
169
+ }
163
170
  const servicePid = 'hammerkit-pid' in currentService.Labels ? parseInt(currentService.Labels['hammerkit-pid']) : undefined;
164
171
  const serviceState = 'hammerkit-state' in currentService.Labels ? currentService.Labels['hammerkit-state'] : '';
165
172
  state.set({
@@ -181,6 +188,7 @@ function dockerServiceRuntime(service, workEnvironment) {
181
188
  for (const container of containers) {
182
189
  await (0, remove_container_1.removeContainer)(docker.getContainer(container.Id));
183
190
  }
191
+ await (0, container_secrets_1.removeContainerSecrets)((0, work_instance_id_1.getWorkInstanceId)(service));
184
192
  },
185
193
  async remove() {
186
194
  await this.stop();
@@ -5,6 +5,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.kubernetesForwardRuntime = exports.kubernetesServiceRuntime = exports.kubernetesTaskRuntime = void 0;
7
7
  const abort_1 = require("../executer/abort");
8
+ const get_service_ip_1 = require("../kubernetes/get-service-ip");
9
+ const work_item_1 = require("./work-item");
8
10
  const kubernetes_service_1 = require("../executer/kubernetes-service");
9
11
  const kubernetes_instance_1 = require("../kubernetes/kubernetes-instance");
10
12
  const replace_env_variables_1 = require("../environment/replace-env-variables");
@@ -23,6 +25,7 @@ const version_1 = require("../version");
23
25
  const restore_kubernetes_data_1 = require("../kubernetes/restore-kubernetes-data");
24
26
  const store_kubernetes_data_1 = require("../kubernetes/store-kubernetes-data");
25
27
  const remove_persistent_data_1 = require("../kubernetes/remove-persistent-data");
28
+ const secrets_1 = require("../kubernetes/secrets");
26
29
  async function listJobNames(instance, kubernetes, task) {
27
30
  const jobs = await instance.batchApi.listNamespacedJob({
28
31
  namespace: kubernetes.namespace,
@@ -47,11 +50,31 @@ function kubernetesTaskRuntime(task, kubernetes) {
47
50
  const persistence = await (0, volumes_1.getKubernetesPersistence)(task);
48
51
  await (0, ensure_namespace_1.ensureNamespace)(instance, kubernetes.namespace);
49
52
  await (0, ensure_persistent_data_1.ensurePersistentData)(instance, kubernetes, environment, task, persistence);
53
+ const secrets = (0, secrets_1.getKubernetesSecretRefs)(task, task.data.secrets);
54
+ try {
55
+ await (0, secrets_1.ensureKubernetesSecret)(instance, kubernetes, task, task.data.secrets, environment);
56
+ }
57
+ catch (e) {
58
+ options.state.set({ stateKey: options.stateKey, type: 'error', errorMessage: (0, log_1.getErrorMessage)(e) });
59
+ return;
60
+ }
50
61
  // The last job of a run stays: its state label is what currentStateKey
51
62
  // reads. Jobs from an earlier run of this task go before this one starts.
52
63
  for (const name of await listJobNames(instance, kubernetes, task)) {
53
64
  await (0, await_running_state_1.deleteJobAndWait)(instance, kubernetes, name, options.abort);
54
65
  }
66
+ // the services the task needs, by the names it lists them under
67
+ const hostAliases = [];
68
+ for (const need of task.needs) {
69
+ if (!(0, work_item_1.isContainerWorkServiceItem)(need.service)) {
70
+ continue;
71
+ }
72
+ const ip = await (0, get_service_ip_1.getServiceIp)(instance, kubernetes, need.service);
73
+ if (!ip) {
74
+ throw new Error(`unable to get service ip for ${need.name}`);
75
+ }
76
+ hostAliases.push({ ip, hostnames: [need.name] });
77
+ }
55
78
  const podName = `${task.name}-${options.stateKey}`;
56
79
  let i = 0;
57
80
  for (const cmd of task.data.cmds) {
@@ -84,15 +107,20 @@ function kubernetesTaskRuntime(task, kubernetes) {
84
107
  containers: [
85
108
  {
86
109
  image: task.data.image,
87
- command: [cmd.parsed.command],
88
- args: cmd.parsed.args,
110
+ // through the task's shell, as on Docker
111
+ command: [task.data.shell, '-c'],
112
+ args: [cmd.cmd],
89
113
  workingDir: cmd.cwd,
90
- env: Object.entries(envs).map(([key, value]) => ({ name: key, value })),
114
+ env: [
115
+ ...Object.entries(envs).map(([key, value]) => ({ name: key, value })),
116
+ ...secrets.env,
117
+ ],
91
118
  name: `cmd-${++i}`,
92
- volumeMounts: persistence.mounts.map((m) => m.mount),
119
+ volumeMounts: [...persistence.mounts.map((m) => m.mount), ...secrets.mounts],
93
120
  },
94
121
  ],
95
- volumes: persistence.volumes,
122
+ volumes: [...persistence.volumes, ...secrets.volumes],
123
+ hostAliases,
96
124
  restartPolicy: 'Never',
97
125
  },
98
126
  },
@@ -121,9 +149,12 @@ function kubernetesTaskRuntime(task, kubernetes) {
121
149
  type: 'error',
122
150
  errorMessage: (0, log_1.getErrorMessage)(e),
123
151
  });
152
+ await (0, secrets_1.removeKubernetesSecret)(instance, kubernetes, task).catch(() => undefined);
124
153
  return;
125
154
  }
126
155
  }
156
+ // the finished job stays for its state, the values it read need not
157
+ await (0, secrets_1.removeKubernetesSecret)(instance, kubernetes, task);
127
158
  },
128
159
  async stop() {
129
160
  const jobs = await instance.batchApi.listNamespacedJob({
@@ -160,6 +191,7 @@ function kubernetesTaskRuntime(task, kubernetes) {
160
191
  async remove() {
161
192
  await this.stop();
162
193
  await (0, remove_persistent_data_1.removePersistentData)(instance, kubernetes, task);
194
+ await (0, secrets_1.removeKubernetesSecret)(instance, kubernetes, task);
163
195
  },
164
196
  async currentStateKey() {
165
197
  const jobs = await instance.batchApi.listNamespacedJob({
@@ -217,13 +249,15 @@ function kubernetesServiceRuntime(service, kubernetes) {
217
249
  });
218
250
  }
219
251
  }
252
+ await (0, secrets_1.removeKubernetesSecret)(instance, kubernetes, service);
220
253
  },
221
254
  async execute(environment, options) {
222
255
  const persistence = await (0, volumes_1.getKubernetesPersistence)(service);
223
256
  await (0, ensure_namespace_1.ensureNamespace)(instance, kubernetes.namespace);
224
257
  await (0, ensure_kubernetes_service_exists_1.ensureKubernetesServiceExists)(instance, kubernetes, service);
225
258
  await (0, ensure_persistent_data_1.ensurePersistentData)(instance, kubernetes, environment, service, persistence);
226
- await (0, ensure_kubernetes_deployment_exists_1.ensureKubernetesDeploymentExists)(instance, kubernetes, service, persistence, options.stateKey);
259
+ await (0, secrets_1.ensureKubernetesSecret)(instance, kubernetes, service, service.data.secrets, environment);
260
+ await (0, ensure_kubernetes_deployment_exists_1.ensureKubernetesDeploymentExists)(instance, kubernetes, service, persistence, (0, secrets_1.getKubernetesSecretRefs)(service, service.data.secrets), options.stateKey);
227
261
  const name = (0, resources_1.getResourceName)(service);
228
262
  options.state.set({
229
263
  type: 'running',
@@ -0,0 +1,83 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getSecretDigest = exports.resolveSecretValue = exports.parseWorkSecrets = exports.getPortableSecretName = exports.getSecretName = void 0;
4
+ const crypto_1 = require("crypto");
5
+ const os_1 = require("os");
6
+ const fs_1 = require("fs");
7
+ const normalize_path_1 = require("./utils/normalize-path");
8
+ const template_value_1 = require("./utils/template-value");
9
+ const portable_path_1 = require("./utils/portable-path");
10
+ const redact_1 = require("../utils/redact");
11
+ function getSecretName(secret) {
12
+ return secret.target.type === 'env' ? secret.target.name : secret.target.path;
13
+ }
14
+ exports.getSecretName = getSecretName;
15
+ // The name as it reads on every checkout, for cache identity: a file target
16
+ // lives under the task's cwd, which is a different absolute path per machine.
17
+ function getPortableSecretName(projectRoot, secret) {
18
+ return secret.target.type === 'env' ? secret.target.name : (0, portable_path_1.portablePath)(projectRoot, secret.target.path);
19
+ }
20
+ exports.getPortableSecretName = getPortableSecretName;
21
+ function parseWorkSecrets(cwd, projectRoot, secrets, envs, environment) {
22
+ return (secrets ?? []).map((secret) => {
23
+ const ref = secret.from.substring(secret.from.indexOf(':') + 1);
24
+ const source = secret.from.startsWith('env:')
25
+ ? { type: 'env', name: ref }
26
+ : { type: 'file', path: (0, normalize_path_1.normalizePath)(cwd, (0, os_1.homedir)(), (0, template_value_1.templateValue)(ref, envs)) };
27
+ const target = secret.env
28
+ ? { type: 'env', name: secret.env }
29
+ : { type: 'file', path: (0, normalize_path_1.normalizePath)(cwd, cwd, (0, template_value_1.templateValue)(secret.path ?? '', envs)) };
30
+ const workSecret = {
31
+ source,
32
+ target,
33
+ cache: secret.cache ?? false,
34
+ digest: () => getSecretDigest(projectRoot, cwd, workSecret, readSecretValueSync(workSecret, environment)),
35
+ };
36
+ return workSecret;
37
+ });
38
+ }
39
+ exports.parseWorkSecrets = parseWorkSecrets;
40
+ function readSecretValueSync(secret, environment) {
41
+ if (secret.source.type === 'env') {
42
+ const value = environment.processEnvs[secret.source.name];
43
+ if (value === undefined || value === null) {
44
+ throw new Error(`secret ${getSecretName(secret)}: environment variable ${secret.source.name} is not set`);
45
+ }
46
+ (0, redact_1.registerSecretValue)(value);
47
+ return value;
48
+ }
49
+ if (!(0, fs_1.existsSync)(secret.source.path)) {
50
+ throw new Error(`secret ${getSecretName(secret)}: file ${secret.source.path} does not exist`);
51
+ }
52
+ const value = (0, fs_1.readFileSync)(secret.source.path, 'utf8');
53
+ (0, redact_1.registerSecretValue)(value);
54
+ return value;
55
+ }
56
+ // The value, read when the task or service starts (or when a cache-affecting
57
+ // secret is hashed); a missing source fails naming the secret.
58
+ async function resolveSecretValue(secret, environment) {
59
+ if (secret.source.type === 'env') {
60
+ const value = environment.processEnvs[secret.source.name];
61
+ if (value === undefined || value === null) {
62
+ throw new Error(`secret ${getSecretName(secret)}: environment variable ${secret.source.name} is not set`);
63
+ }
64
+ (0, redact_1.registerSecretValue)(value);
65
+ return value;
66
+ }
67
+ if (!(await environment.file.exists(secret.source.path))) {
68
+ throw new Error(`secret ${getSecretName(secret)}: file ${secret.source.path} does not exist`);
69
+ }
70
+ const value = await environment.file.read(secret.source.path);
71
+ (0, redact_1.registerSecretValue)(value);
72
+ return value;
73
+ }
74
+ exports.resolveSecretValue = resolveSecretValue;
75
+ // Salted with the project-relative location and the secret's name, so the
76
+ // digest is identical on every checkout (remote cache ids match) but can't be
77
+ // matched against digests of the same value elsewhere.
78
+ function getSecretDigest(projectRoot, cwd, secret, value) {
79
+ const salt = `hammerkit-secret\0${(0, portable_path_1.portablePath)(projectRoot, cwd)}\0${getPortableSecretName(projectRoot, secret)}`;
80
+ return `sha256:${(0, crypto_1.createHash)('sha256').update(`${salt}\0${value}`).digest('hex')}`;
81
+ }
82
+ exports.getSecretDigest = getSecretDigest;
83
+ //# sourceMappingURL=work-secret.js.map
package/dist/program.js CHANGED
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.getProgram = exports.createCli = void 0;
7
7
  const commander_1 = require("commander");
8
+ const parse_set_arguments_1 = require("./parse-set-arguments");
8
9
  const path_1 = require("path");
9
10
  const ci_1 = require("./utils/ci");
10
11
  const parse_label_arguments_1 = require("./parser/parse-label-arguments");
@@ -56,7 +57,9 @@ function parseWorkLabelScope(options) {
56
57
  }
57
58
  async function getProgram(environment, argv) {
58
59
  const program = new commander_1.Command();
59
- const args = [...argv];
60
+ const parsed = (0, parse_set_arguments_1.parseSetArguments)(argv);
61
+ const args = parsed.args;
62
+ environment.processEnvs = { ...environment.processEnvs, ...parsed.values };
60
63
  const fileIndex = args.indexOf('--file');
61
64
  const fileName = fileIndex >= 0 ? (0, path_1.join)(environment.cwd, args[fileIndex + 1]) : await (0, default_build_file_1.getBuildFilename)(environment.cwd, environment);
62
65
  if (fileIndex >= 0) {
@@ -421,6 +424,9 @@ async function getProgram(environment, argv) {
421
424
  .addOption(new commander_1.Option('-c, --concurrency <number>', 'parallel worker count').argParser(parseInt).default(4))
422
425
  .addOption(new commander_1.Option('-w, --watch', 'watch tasks').default(false))
423
426
  .addOption(new commander_1.Option('-d, --daemon', 'run services in background').default(false))
427
+ .addOption(new commander_1.Option('--wait <mode>', 'with --daemon, return once services are ready or once they started')
428
+ .default('ready')
429
+ .choices(['ready', 'start']))
424
430
  .addOption(new commander_1.Option('--env <name>', 'environment'))
425
431
  .addOption(new commander_1.Option('-l, --log <mode>', 'log mode')
426
432
  .default(ci_1.isCI ? 'live' : 'interactive')
@@ -440,6 +446,7 @@ async function getProgram(environment, argv) {
440
446
  workers: options.concurrency,
441
447
  logMode: options.log,
442
448
  daemon: options.daemon,
449
+ wait: options.wait,
443
450
  cacheReadOnly: (0, read_only_1.isCacheReadOnly)(options.cacheReadOnly, environment.processEnvs),
444
451
  timeout: options.timeout ?? null,
445
452
  });
@@ -593,6 +600,7 @@ tasks:
593
600
  program.version((0, version_1.getVersion)());
594
601
  program.option('--verbose', 'log debugging information', false);
595
602
  program.option('--file', 'set build file', '.hammerkit.yaml');
603
+ program.option('--set <NAME=value>', 'value for $NAME / ${NAME} references, ahead of the shell and .env (repeatable)');
596
604
  program.configureOutput({
597
605
  writeOut: (str) => environment.console.info(str),
598
606
  writeErr: (str) => {
@@ -9,6 +9,7 @@ const cache_schema_1 = require("./cache-schema");
9
9
  const build_file_task_command_schema_1 = require("./build-file-task-command-schema");
10
10
  const build_file_volume_schema_1 = require("./build-file-volume-schema");
11
11
  const duration_schema_1 = require("./duration-schema");
12
+ const build_file_secret_schema_1 = require("./build-file-secret-schema");
12
13
  exports.buildFileBaseTaskSchema = (0, zod_1.object)({
13
14
  deps: (0, zod_1.array)((0, zod_1.string)()).optional(),
14
15
  needs: (0, zod_1.array)(build_file_need_schema_1.buildFileNeedSchema).optional(),
@@ -27,5 +28,6 @@ exports.buildFileBaseTaskSchema = (0, zod_1.object)({
27
28
  // Maximum execution time, e.g. `30s`, `10m`, `1h30m`. The task fails when it
28
29
  // runs longer. Does not affect the cache key (ADR-0002).
29
30
  timeout: duration_schema_1.durationSchema.optional(),
31
+ secrets: (0, zod_1.array)(build_file_secret_schema_1.buildFileSecretSchema).optional(),
30
32
  });
31
33
  //# sourceMappingURL=build-file-base-task-schema.js.map
@@ -6,6 +6,7 @@ const envs_schema_1 = require("./envs-schema");
6
6
  const build_file_need_schema_1 = require("./build-file-need-schema");
7
7
  const labels_schema_1 = require("./labels-schema");
8
8
  const build_file_service_container_healthcheck_1 = require("./build-file-service-container-healthcheck");
9
+ const build_file_secret_schema_1 = require("./build-file-secret-schema");
9
10
  exports.buildFileContainerServiceSchema = (0, zod_1.object)({
10
11
  image: (0, zod_1.string)(),
11
12
  description: (0, zod_1.string)().optional(),
@@ -30,6 +31,10 @@ exports.buildFileContainerServiceSchema = (0, zod_1.object)({
30
31
  labels: labels_schema_1.labelsSchema.optional(),
31
32
  continuous: (0, zod_1.boolean)().optional(),
32
33
  healthcheck: build_file_service_container_healthcheck_1.buildFileServiceContainerHealthcheck.optional(),
34
+ // a task (name or prefix:name, as in deps) that runs once the healthcheck
35
+ // passed; the service counts as ready for dependents only after it succeeded
36
+ init: (0, zod_1.string)().describe('task run once the service is healthy, before dependents start').optional(),
37
+ secrets: (0, zod_1.array)(build_file_secret_schema_1.buildFileSecretSchema).optional(),
33
38
  })
34
39
  .strict()
35
40
  .describe('container service');
@@ -0,0 +1,22 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.buildFileSecretSchema = void 0;
4
+ const zod_1 = require("zod");
5
+ // A credential handed to a task or service without putting it in the build
6
+ // file, the cache or the logs: `from: env:NAME` or `from: file:path`, injected
7
+ // as an env variable (`env`) or a read-only file (`path`). `cache: true` makes
8
+ // a salted digest of the value part of the task's cache key (ADR 0002).
9
+ exports.buildFileSecretSchema = (0, zod_1.object)({
10
+ from: (0, zod_1.string)().regex(/^(env|file):.+$/, 'from must be env:NAME or file:path'),
11
+ env: (0, zod_1.string)()
12
+ .regex(/^[A-Za-z_][A-Za-z0-9_]*$/)
13
+ .optional(),
14
+ path: (0, zod_1.string)().optional(),
15
+ cache: (0, zod_1.boolean)().optional(),
16
+ })
17
+ .strict()
18
+ .refine((secret) => (secret.env === undefined) !== (secret.path === undefined), {
19
+ message: 'a secret needs exactly one of env or path',
20
+ })
21
+ .describe('secret from env:NAME or file:path, injected as env or a read-only file');
22
+ //# sourceMappingURL=build-file-secret-schema.js.map
@@ -5,10 +5,12 @@ const zod_1 = require("zod");
5
5
  exports.buildFileVolumeSchema = (0, zod_1.union)([
6
6
  (0, zod_1.string)(),
7
7
  (0, zod_1.object)({
8
- export: (0, zod_1.boolean)().optional(),
8
+ // `always`: copied to the host even when the task fails (test reports)
9
+ export: (0, zod_1.union)([(0, zod_1.boolean)(), (0, zod_1.literal)('always')]).optional(),
9
10
  resetOnChange: (0, zod_1.boolean)().optional(),
10
11
  path: (0, zod_1.string)(),
11
12
  name: (0, zod_1.string)().optional(),
13
+ readOnly: (0, zod_1.boolean)().optional(),
12
14
  }),
13
15
  ]);
14
16
  //# sourceMappingURL=build-file-volume-schema.js.map