razzou-harness 0.4.2 → 0.4.3
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 +15 -0
- package/README.md +12 -3
- package/dist/setup/doctor.js +1 -1
- package/dist/setup/setup.js +48 -0
- package/dist/setup/terraform.js +86 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,21 @@
|
|
|
3
3
|
The notable changes in each release of razzou-harness. Before 1.0, a new minor version
|
|
4
4
|
may change how a project is set up, and its notes say how.
|
|
5
5
|
|
|
6
|
+
## 0.4.3 (2026-11-08)
|
|
7
|
+
|
|
8
|
+
**Upgrading from 0.4.2:** install the new version, then rebuild the agents' image with
|
|
9
|
+
`harness doctor --build-images`: its tag follows the harness's version.
|
|
10
|
+
|
|
11
|
+
- **`harness setup` writes the Terraform scaffold** your bootstrap expects, when you
|
|
12
|
+
answer its AWS questions, and on any later run: a partial `backend "s3" {}` block in
|
|
13
|
+
`iac/versions.tf`, `iac/backend-configs/staging.hcl` with the state bucket and the
|
|
14
|
+
project's key, read from the GitHub variables the bootstrap set, `iac/env/staging.tfvars`
|
|
15
|
+
with the account, the region and the repository's ids, and the variables and provider
|
|
16
|
+
they feed. Only missing files are written, a backend block of the project's own is
|
|
17
|
+
kept, and the files are committed with setup's others. The backend config sets no
|
|
18
|
+
encryption, so the bucket's default, its KMS key, applies. Until now the doctor
|
|
19
|
+
required these files and nothing wrote them.
|
|
20
|
+
|
|
6
21
|
## 0.4.2 (2026-11-08)
|
|
7
22
|
|
|
8
23
|
**Upgrading from 0.4.1:** upgrade OpenShell to 0.1.7 first, from its Debian package
|
package/README.md
CHANGED
|
@@ -140,7 +140,8 @@ gateway key. In order, it logs you in to `gh`; creates
|
|
|
140
140
|
the repository on GitHub if the project has no remote; creates the App from its
|
|
141
141
|
manifest when this machine has none, and waits for you to install it; writes the
|
|
142
142
|
harness's files; asks the questions and writes `harness/config.json`, with the
|
|
143
|
-
`staging` environment and the variables on GitHub for AWS
|
|
143
|
+
`staging` environment and the variables on GitHub for AWS, and the Terraform scaffold in
|
|
144
|
+
`iac/` that your bootstrap expects; commits the files it wrote,
|
|
144
145
|
and only those, with your git once you agree, and pushes them when you are on `main`;
|
|
145
146
|
runs `harness doctor` and fixes what it can (starts the OpenShell gateway, builds the
|
|
146
147
|
agents' image, imports the Claude profile and creates the provider with your token,
|
|
@@ -433,7 +434,7 @@ through an `Arch:` pull request, or a commit of your own on `main`.
|
|
|
433
434
|
|
|
434
435
|
```json
|
|
435
436
|
{
|
|
436
|
-
"harnessVersion": "0.4.
|
|
437
|
+
"harnessVersion": "0.4.3",
|
|
437
438
|
"project": { "name": "shop", "repo": "<org>/shop", "bot": "<org>-harness[bot]" },
|
|
438
439
|
"roles": {
|
|
439
440
|
"architect": { "model": "claude-opus-5-5", "effort": "max" },
|
|
@@ -554,7 +555,8 @@ and Terraform applies only in CI, after you merge.
|
|
|
554
555
|
`harness-*` roles and to the gateway user, so your project's own Terraform can't widen
|
|
555
556
|
the agents' access.
|
|
556
557
|
|
|
557
|
-
**Then, in the project
|
|
558
|
+
**Then, in the project** (`harness setup` does steps 1 to 3 when you answer its AWS
|
|
559
|
+
questions):
|
|
558
560
|
1. In `harness/config.json`, add `aws` and `terraform`:
|
|
559
561
|
|
|
560
562
|
```json
|
|
@@ -570,6 +572,13 @@ and Terraform applies only in CI, after you merge.
|
|
|
570
572
|
([NVIDIA/OpenShell#5375](https://github.com/NVIDIA/OpenShell/issues/5375)). Keep the `terraform` paths
|
|
571
573
|
as they are: the CI workflow uses the same ones. `harness github-setup` then requires
|
|
572
574
|
`tf-plan` as well.
|
|
575
|
+
|
|
576
|
+
In `iac/`, the Terraform scaffold: a partial `backend "s3" {}` block, the backend
|
|
577
|
+
config with the state bucket, the project's key, the region and `use_lockfile = true`,
|
|
578
|
+
and the variables file. `harness setup` writes whatever of it is missing, from the
|
|
579
|
+
variables your bootstrap set on GitHub. Its backend config sets no encryption, so the
|
|
580
|
+
bucket's default, your KMS key, applies: `encrypt = true` without `kms_key_id` would
|
|
581
|
+
replace it with SSE-S3.
|
|
573
582
|
2. Set the repository variables the workflow reads: `AWS_REGION_STAGING`,
|
|
574
583
|
`AWS_ROLE_PLAN_ARN_STAGING` and `AWS_ROLE_APPLY_ARN_STAGING`.
|
|
575
584
|
3. `harness doctor` creates the OpenShell AWS provider: it asks for the view role's ARN,
|
package/dist/setup/doctor.js
CHANGED
|
@@ -218,7 +218,7 @@ async function project(options, config, report) {
|
|
|
218
218
|
if (config.terraform) {
|
|
219
219
|
const tf = config.terraform;
|
|
220
220
|
for (const file of [tf.backendConfig, tf.varFile]) {
|
|
221
|
-
report(existsSync(join(options.projectDir, tf.dir, file)) ? ok('Terraform', `${tf.dir}/${file}`) : fail('Terraform', `${tf.dir}/${file} is missing`, 'see
|
|
221
|
+
report(existsSync(join(options.projectDir, tf.dir, file)) ? ok('Terraform', `${tf.dir}/${file}`) : fail('Terraform', `${tf.dir}/${file} is missing`, '`harness setup` writes the missing Terraform scaffold, or see the README\'s AWS section'));
|
|
222
222
|
}
|
|
223
223
|
}
|
|
224
224
|
}
|
package/dist/setup/setup.js
CHANGED
|
@@ -7,6 +7,7 @@ import { applyRepositoryRules, checkApp, createApp, installCi } from '../github/
|
|
|
7
7
|
import { run, sha256 } from '../util.js';
|
|
8
8
|
import { doctor, granted } from './doctor.js';
|
|
9
9
|
import { appBot, githubRepo, init } from './init.js';
|
|
10
|
+
import { writeTerraformScaffold } from './terraform.js';
|
|
10
11
|
export function fixFor(check) {
|
|
11
12
|
if (check.status === 'ok' || check.status === 'skip')
|
|
12
13
|
return undefined;
|
|
@@ -214,6 +215,7 @@ async function askSettings(options) {
|
|
|
214
215
|
const written = [];
|
|
215
216
|
let changed = false;
|
|
216
217
|
let viewRole;
|
|
218
|
+
let account;
|
|
217
219
|
if (!config.aws && /^y/i.test(await ask('Does this project deploy to AWS with Terraform? [y/N] '))) {
|
|
218
220
|
say('The roles come from your bootstrap (see the README\'s AWS section); the key is asked for later, at a hidden prompt.');
|
|
219
221
|
const region = (await ask('AWS region (like eu-west-1): ')).trim();
|
|
@@ -231,6 +233,28 @@ async function askSettings(options) {
|
|
|
231
233
|
const repo = config.project?.repo;
|
|
232
234
|
if (repo)
|
|
233
235
|
await configureGitHubForAws(options, repo, { region, plan, apply });
|
|
236
|
+
account = /^arn:aws:iam::(\d{12}):/.exec(view)[1];
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
// The Terraform scaffold the bootstrap expects, which the doctor checks: whatever of it is
|
|
240
|
+
// missing, on this run or a later one. A later run reads the account from the plan role
|
|
241
|
+
// this one stored on GitHub.
|
|
242
|
+
if (config.aws?.region && config.terraform) {
|
|
243
|
+
const repo = config.project?.repo;
|
|
244
|
+
account ??= /^arn:aws:iam::(\d{12}):/.exec((await githubVariable(repo, 'AWS_ROLE_PLAN_ARN_STAGING')) ?? '')?.[1];
|
|
245
|
+
if (account) {
|
|
246
|
+
const layout = { dir: 'iac', backendConfig: 'backend-configs/staging.hcl', varFile: 'env/staging.tfvars', ...config.terraform };
|
|
247
|
+
// As the schema reads it: `iac`, `./iac` and `iac/` are one folder. Slashes are trimmed by hand: a regex would take quadratic time on a long run of them.
|
|
248
|
+
let dir = layout.dir;
|
|
249
|
+
while (dir.startsWith('./') || dir.startsWith('/'))
|
|
250
|
+
dir = dir.slice(dir.startsWith('./') ? 2 : 1);
|
|
251
|
+
while (dir.endsWith('/'))
|
|
252
|
+
dir = dir.slice(0, -1);
|
|
253
|
+
layout.dir = dir || 'iac';
|
|
254
|
+
const scaffold = writeTerraformScaffold(projectDir, layout, await terraformFacts(repo, String(config.project?.name ?? 'project'), String(config.aws.region), account));
|
|
255
|
+
if (scaffold.length > 0)
|
|
256
|
+
say(`Terraform: wrote ${scaffold.join(', ')}.`);
|
|
257
|
+
written.push(...scaffold);
|
|
234
258
|
}
|
|
235
259
|
}
|
|
236
260
|
if (changed) {
|
|
@@ -254,6 +278,30 @@ async function askSettings(options) {
|
|
|
254
278
|
}
|
|
255
279
|
return { written, ...(viewRole ? { viewRole } : {}) };
|
|
256
280
|
}
|
|
281
|
+
/**
|
|
282
|
+
* Where the project's state lives, and its repository's numeric ids: read from GitHub, where
|
|
283
|
+
* the bootstrap's configure-github.sh set TF_STATE_BUCKET_STAGING and TF_STATE_KEY_STAGING,
|
|
284
|
+
* or else by the bootstrap's naming, `tf-state-staging-<account>` and
|
|
285
|
+
* `staging/<repository>/terraform.tfstate`.
|
|
286
|
+
*/
|
|
287
|
+
async function terraformFacts(repo, name, region, account) {
|
|
288
|
+
const bucket = await githubVariable(repo, 'TF_STATE_BUCKET_STAGING');
|
|
289
|
+
const key = await githubVariable(repo, 'TF_STATE_KEY_STAGING');
|
|
290
|
+
const ids = (await ghApi(repo, `repos/${repo}`, '"\\(.owner.id) \\(.id)"'))?.split(' ');
|
|
291
|
+
return {
|
|
292
|
+
region,
|
|
293
|
+
account,
|
|
294
|
+
bucket: bucket ?? `tf-state-staging-${account}`,
|
|
295
|
+
key: key ?? `staging/${repo ? repo.split('/')[1] : name}/terraform.tfstate`,
|
|
296
|
+
...(repo && ids?.length === 2 ? { github: { repo, ownerId: ids[0], repoId: ids[1] } } : {}),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
/** What the owner's gh reads at `path`, through `jq`; undefined without a repository, or on any failure. */
|
|
300
|
+
async function ghApi(repo, path, jq) {
|
|
301
|
+
const result = repo ? await run('gh', ['api', path, '--jq', jq]).catch(() => undefined) : undefined;
|
|
302
|
+
return result?.code === 0 && result.stdout.trim() ? result.stdout.trim() : undefined;
|
|
303
|
+
}
|
|
304
|
+
const githubVariable = (repo, name) => ghApi(repo, `repos/${repo}/actions/variables/${name}`, '.value');
|
|
257
305
|
/** The staging environment, restricted to main, and the variables the workflow reads, with the owner's gh. */
|
|
258
306
|
async function configureGitHubForAws(options, repo, aws) {
|
|
259
307
|
const gh = async (args, body) => {
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* Writes the Terraform scaffold the bootstrap expects in the layout's folder: a partial
|
|
5
|
+
* `backend "s3"` block in `versions.tf`, the backend config and the variables file, and the
|
|
6
|
+
* variables and provider those values feed. Only what is missing is written: an existing
|
|
7
|
+
* file, or a backend block in any of the folder's `.tf` files, is the project's own. The
|
|
8
|
+
* backend config sets no encryption, so the bucket's default, the bootstrap's KMS key,
|
|
9
|
+
* applies: `encrypt = true` without `kms_key_id` would replace it with SSE-S3. Every file
|
|
10
|
+
* is as `terraform fmt` writes it, which CI checks. Returns the paths written, from the
|
|
11
|
+
* project's root.
|
|
12
|
+
*/
|
|
13
|
+
export function writeTerraformScaffold(projectDir, layout, values) {
|
|
14
|
+
const { dir } = layout;
|
|
15
|
+
const written = [];
|
|
16
|
+
const write = (path, content) => {
|
|
17
|
+
const file = join(projectDir, dir, path);
|
|
18
|
+
if (existsSync(file))
|
|
19
|
+
return false;
|
|
20
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
21
|
+
writeFileSync(file, content);
|
|
22
|
+
written.push(`${dir}/${path}`);
|
|
23
|
+
return true;
|
|
24
|
+
};
|
|
25
|
+
const folder = join(projectDir, dir);
|
|
26
|
+
const hasBackend = existsSync(folder)
|
|
27
|
+
&& readdirSync(folder).filter((f) => f.endsWith('.tf')).some((f) => /^\s*backend\s+"/m.test(readFileSync(join(folder, f), 'utf8')));
|
|
28
|
+
if (!hasBackend) {
|
|
29
|
+
write('versions.tf', [
|
|
30
|
+
'terraform {',
|
|
31
|
+
' required_version = ">= 1.10"',
|
|
32
|
+
'',
|
|
33
|
+
' required_providers {',
|
|
34
|
+
' aws = {',
|
|
35
|
+
' source = "hashicorp/aws"',
|
|
36
|
+
' version = "~> 6.65"',
|
|
37
|
+
' }',
|
|
38
|
+
' }',
|
|
39
|
+
'',
|
|
40
|
+
` # Partial configuration: the values are in ${layout.backendConfig}, for`,
|
|
41
|
+
` # terraform init -backend-config=${layout.backendConfig}`,
|
|
42
|
+
' backend "s3" {}',
|
|
43
|
+
'}',
|
|
44
|
+
'',
|
|
45
|
+
].join('\n'));
|
|
46
|
+
}
|
|
47
|
+
write(layout.backendConfig, [
|
|
48
|
+
...assignments([['bucket', values.bucket], ['key', values.key], ['region', values.region]]),
|
|
49
|
+
'',
|
|
50
|
+
"# No encrypt or kms_key_id: the bucket's default encryption, its KMS key, applies.",
|
|
51
|
+
'# encrypt = true without kms_key_id would replace it with SSE-S3.',
|
|
52
|
+
'use_lockfile = true',
|
|
53
|
+
'',
|
|
54
|
+
].join('\n'));
|
|
55
|
+
const github = values.github;
|
|
56
|
+
const variables = [
|
|
57
|
+
['env', 'staging', 'The environment.'],
|
|
58
|
+
['aws_account_id', values.account, 'The AWS account the environment is in.'],
|
|
59
|
+
['aws_region', values.region, 'The AWS region the environment is in.'],
|
|
60
|
+
...(github ? [
|
|
61
|
+
['github_repo', github.repo, 'The GitHub repository, as owner/name.'],
|
|
62
|
+
['github_owner_id', github.ownerId, "The repository owner's numeric id, which OIDC trust policies pin."],
|
|
63
|
+
['github_repo_id', github.repoId, "The repository's numeric id, which OIDC trust policies pin."],
|
|
64
|
+
] : []),
|
|
65
|
+
];
|
|
66
|
+
const tfvars = [
|
|
67
|
+
...assignments(variables.slice(0, 3).map(([name, value]) => [name, value])),
|
|
68
|
+
...(github ? ['', ...assignments(variables.slice(3).map(([name, value]) => [name, value]))] : []),
|
|
69
|
+
'',
|
|
70
|
+
].join('\n');
|
|
71
|
+
// The variables follow the values: a tfvars file of the project's own sets its own.
|
|
72
|
+
if (write(layout.varFile, tfvars)) {
|
|
73
|
+
write('variables.tf', variables.map(([name, , description]) => [
|
|
74
|
+
`variable "${name}" {`,
|
|
75
|
+
...assignments([['description', description], ['type', 'string']], ['type']).map((line) => ` ${line}`),
|
|
76
|
+
'}',
|
|
77
|
+
].join('\n')).join('\n\n') + '\n');
|
|
78
|
+
write('providers.tf', ['provider "aws" {', ' region = var.aws_region', '}', ''].join('\n'));
|
|
79
|
+
}
|
|
80
|
+
return written;
|
|
81
|
+
}
|
|
82
|
+
/** `name = "value"` lines with their equals signs aligned, as `terraform fmt` aligns them; `bare` names take an expression, unquoted. */
|
|
83
|
+
function assignments(pairs, bare = []) {
|
|
84
|
+
const width = Math.max(...pairs.map(([name]) => name.length));
|
|
85
|
+
return pairs.map(([name, value]) => `${name.padEnd(width)} = ${bare.includes(name) ? value : JSON.stringify(value)}`);
|
|
86
|
+
}
|
package/package.json
CHANGED