|
| 1 | +# REST command bundle contract |
| 2 | + |
| 3 | +REST has 11 standalone command names, while the production Linux images also |
| 4 | +need `nicocli` and `nico-mcp`. Building each command in its own job repeated the |
| 5 | +same checkout and Go setup, and it left the Docker builds with no checked bundle |
| 6 | +they could reuse. |
| 7 | + |
| 8 | +So, [`rest-command-manifest.json`](rest-command-manifest.json) is the one list of |
| 9 | +REST commands this workflow may build. One job builds everything for a target, |
| 10 | +checks each binary, and uploads one archive with the exact build record a later |
| 11 | +job needs. |
| 12 | + |
| 13 | +## What can be built |
| 14 | + |
| 15 | +The source manifest is an object with exactly three required fields: |
| 16 | + |
| 17 | +- `schema_version` is the integer `1`. |
| 18 | +- `targets` is a non-empty array. Each entry has the non-empty strings `name`, |
| 19 | + `goos`, and `goarch`; `name` must be the literal `<goos>-<goarch>` value, and |
| 20 | + target names must be unique. The supported names are exactly `linux-amd64`, |
| 21 | + `linux-arm64`, and `darwin-arm64`. |
| 22 | +- `outputs` is a non-empty array. Each entry has `name`, `package`, `target`, |
| 23 | + `cgo_enabled`, `ldflags`, and `output`. The command name uses lowercase |
| 24 | + letters, numbers, and internal hyphens. `package` starts with `./`, contains |
| 25 | + no empty, `.`, or `..` path components, cannot escape through a symlink, and |
| 26 | + names an existing directory under `--repo-root`. `target` names a declared |
| 27 | + target, `cgo_enabled` is a Boolean, `ldflags` is an array of non-empty |
| 28 | + strings, and `output` is exactly `bin/<name>`. |
| 29 | + |
| 30 | +`cgo_enabled: true` sets `CGO_ENABLED=1`; `false` sets it to `0`. Command names |
| 31 | +and `output` paths must be unique within one target. Every target needs at least |
| 32 | +one output and produces one `rest-command-bundle-<target>.tar.gz` archive; |
| 33 | +source-manifest order becomes resolved-manifest command order. Unknown fields |
| 34 | +and unsafe paths fail validation. |
| 35 | + |
| 36 | +Linker flags may use only `${VERSION}`, `${SHORT_SHA}`, |
| 37 | +`${BUILD_TIME_LEGACY}`, and `${BUILD_TIME_RFC3339}`. Any other dollar form is |
| 38 | +rejected; literal-dollar escaping is unsupported, and a backslash does not |
| 39 | +escape a token. |
| 40 | + |
| 41 | +The checked-in inventory has three deliberate boundaries: |
| 42 | + |
| 43 | +- Linux amd64 and Linux arm64 each build 13 commands. The ten production image |
| 44 | + entrypoints are `api`, `migrations`, `sitemgr`, `workflow`, `site-agent`, |
| 45 | + `credsmgr`, `flow`, `psm`, `nsm`, and `nico-mcp`. They use the same settings |
| 46 | + and metadata fields as their production Dockerfiles. Bundle timestamps use |
| 47 | + canonical UTC values rather than inheriting a runner's local timezone. The |
| 48 | + API image also contains `nicocli`; both `nicocli` and `nico-mcp` are |
| 49 | + Linux-only bundle outputs. |
| 50 | +- `mock-core` and `mock-flow` keep the previous standalone behavior. The native |
| 51 | + Linux amd64 builds use CGO, while the cross-compiled Linux arm64 builds do |
| 52 | + not. That amd64 target therefore needs a working C compiler; build metadata, |
| 53 | + rather than `file` wording, verifies its `CGO_ENABLED=1` setting. |
| 54 | +- Darwin arm64 keeps the previous 11-command standalone contract. The API, |
| 55 | + site-agent, and flow commands keep their runtime version metadata without |
| 56 | + acquiring Linux static-linker flags or new CLI/MCP builds. |
| 57 | + |
| 58 | +All three targets build `api`, `migrations`, `sitemgr`, `workflow`, |
| 59 | +`site-agent`, `mock-core`, `mock-flow`, `credsmgr`, `flow`, `psm`, and `nsm`. |
| 60 | +The two Linux targets also build `nicocli` and `nico-mcp`. The independent |
| 61 | +command-contract test pins the exact package, target, CGO, linker-flag, and |
| 62 | +output inventory; the source manifest remains the canonical machine-readable |
| 63 | +list. |
| 64 | + |
| 65 | +The supported transition boundary is |
| 66 | +[CICD-08](https://github.com/NVIDIA/infra-controller/issues/4582). Until that |
| 67 | +issue changes image packaging to consume these bundles, the production |
| 68 | +Dockerfiles contain a second copy of the Linux service build settings. Keeping |
| 69 | +the two copies aligned is a review responsibility during this transition; |
| 70 | +CICD-08 removes the duplicate build recipe by making packaging consume these |
| 71 | +bundles. |
| 72 | + |
| 73 | +## Commands |
| 74 | + |
| 75 | +`rest_command_bundle.py` has three subcommands. Each accepts optional |
| 76 | +`--manifest PATH`, which defaults to the adjacent |
| 77 | +`.github/ci/rest-command-manifest.json`, and `--repo-root PATH`, which defaults |
| 78 | +to the repository's `rest-api/` directory. Relative path arguments resolve |
| 79 | +from the current working directory; `--repo-root` does not rebase the other |
| 80 | +paths. Absolute paths are accepted. |
| 81 | + |
| 82 | +- `check` has no additional flags. It validates the source manifest and package |
| 83 | + directories, writes no files, and prints the output and target counts. |
| 84 | +- `build` requires `--target`, `--output-dir`, `--version`, |
| 85 | + `--build-timestamp`, `--candidate-sha`, and `--short-sha`; `--allow-dirty` is |
| 86 | + optional. It validates the checkout, compiles and inspects every command for |
| 87 | + one target, creates the output directory when missing, publishes the three |
| 88 | + bundle files, and prints build progress plus the verified output count and |
| 89 | + archive size. An existing output directory must be empty. The accepted |
| 90 | + targets are `linux-amd64`, `linux-arm64`, and `darwin-arm64`. |
| 91 | +- `verify` requires `--target`, `--bundle`, `--checksum`, |
| 92 | + `--resolved-manifest`, `--version`, `--build-timestamp`, `--candidate-sha`, |
| 93 | + and `--short-sha`; `--allow-dirty` is optional. It reads and verifies the |
| 94 | + three bundle files for a declared target, uses a temporary directory for |
| 95 | + binary inspection, removes that directory on exit, and prints the verified |
| 96 | + archive path. The bundle cannot choose its expected identity. |
| 97 | + |
| 98 | +Each subcommand rejects flags owned by another subcommand. In particular, |
| 99 | +`check` rejects `--allow-dirty`, `build` rejects the verify-only bundle path |
| 100 | +flags, and `verify` rejects `--output-dir`. An undeclared target fails in |
| 101 | +`build` or `verify`; there is no target flag for `check`. |
| 102 | + |
| 103 | +The version starts with a letter or number and otherwise accepts only letters, |
| 104 | +numbers, periods, underscores, and hyphens. The build timestamp uses UTC |
| 105 | +`YYYY-MM-DDTHH:MM:SSZ`. The candidate SHA is exactly 40 lowercase hexadecimal |
| 106 | +characters, while the short SHA is its 7- to 12-character prefix. Because that |
| 107 | +timestamp is also written into the gzip header, it must fall between |
| 108 | +`1970-01-01T00:00:00Z` and `2106-02-07T06:28:15Z`, inclusive. |
| 109 | + |
| 110 | +Only `build` and `verify` accept `--allow-dirty`. By default, `build` rejects |
| 111 | +tracked or untracked changes anywhere in the repository worktree, and `verify` |
| 112 | +rejects a resolved manifest with `source_dirty: true`. The flag permits that |
| 113 | +state for local validation. CI does not pass it. |
| 114 | + |
| 115 | +`build` stages the three bundle files inside the output directory, verifies |
| 116 | +them there, and publishes the resolved manifest and checksum before atomically |
| 117 | +renaming the archive into place. A failed build, verification, or publication |
| 118 | +normally removes staged and partially published files so the output directory |
| 119 | +is empty for a retry. If cleanup itself fails, the error names each path it |
| 120 | +could not remove. |
| 121 | + |
| 122 | +All three commands require Python 3.10 or newer. `build` also requires Git, |
| 123 | +[Go 1.26.4 or newer](../../rest-api/go.mod), and the `file` utility; `verify` requires Go |
| 124 | +and `file`. Building `linux-amd64` also requires a C compiler for `mock-core` |
| 125 | +and `mock-flow`. CI checks the Python minimum, selects the module's minimum Go |
| 126 | +1.26.4, and checks `file --version` before compiling. A contract, command, tool, |
| 127 | +or verification failure prints `error: <message>` to standard error and exits |
| 128 | +with status 1. Argument parsing and missing required flags print `usage:` and |
| 129 | +exit with status 2. |
| 130 | + |
| 131 | +## Bundle files |
| 132 | + |
| 133 | +For a target such as `linux-amd64`, `build` writes: |
| 134 | + |
| 135 | +- `rest-command-bundle-linux-amd64.tar.gz`; |
| 136 | +- `rest-command-bundle-linux-amd64.tar.gz.sha256`; and |
| 137 | +- `rest-command-bundle-linux-amd64.manifest.json`. |
| 138 | + |
| 139 | +The UTF-8 checksum sidecar contains exactly one LF-terminated line: one |
| 140 | +lowercase SHA-256 digest, two spaces, the archive filename, and the final LF. |
| 141 | +The producer writes a level-9 gzip stream with no stored filename or optional |
| 142 | +header fields and with the build timestamp as its gzip timestamp. It contains a |
| 143 | +PAX-format tar stream ordered as `manifest.json`, `SHA256SUMS`, and then binary |
| 144 | +paths sorted by name. Those two metadata files use mode `0644`; every |
| 145 | +`bin/<name>` entry uses mode `0755`. All members are regular relative files with |
| 146 | +UID and GID `0`, empty owner and group names, and the build timestamp as their |
| 147 | +modification time. |
| 148 | + |
| 149 | +The binary checksum file uses the same digest and two-space format, with one |
| 150 | +LF-terminated line for every command, `/`-separated relative paths, source |
| 151 | +inventory sorted by path, and no extra entries. The resolved and embedded |
| 152 | +manifests use UTF-8 JSON with sorted object keys, two-space indentation, and one |
| 153 | +final newline; non-ASCII characters are escaped. No required field is omitted. |
| 154 | + |
| 155 | +The resolved manifest records string values for the source-manifest SHA-256, |
| 156 | +requested candidate and short SHA, version, and build timestamp; a Boolean |
| 157 | +`source_dirty`; the exact target object; and a non-empty command array. Each |
| 158 | +command entry records string values for its name, package, output, import path, |
| 159 | +Go version, `file` description, SHA-256, and VCS settings; a Boolean CGO choice; |
| 160 | +a positive integer size; and a possibly empty array whose linker-flag entries |
| 161 | +are non-empty strings. |
| 162 | + |
| 163 | +The flat VCS keys are always present and non-empty. `vcs_modified` records Go's |
| 164 | +`vcs.modified` value or `unknown` when Go omits it. `vcs_revision` records the |
| 165 | +candidate SHA only when Go reports that exact revision; otherwise it is |
| 166 | +`unavailable`. Verification rejects every other revision value. Go normally |
| 167 | +records the repository revision for this module; the fallback keeps the bundle |
| 168 | +contract explicit in environments where those settings are unavailable. |
| 169 | + |
| 170 | +The producer and its unit test own the PAX writer choice. The standalone |
| 171 | +verifier accepts any readable tar encoding, but still enforces the canonical |
| 172 | +JSON bytes, gzip header and timestamp, member order, ownership, timestamps, |
| 173 | +modes, paths, inventory, and contents described above. |
| 174 | + |
| 175 | +## Embedded metadata |
| 176 | + |
| 177 | +The Linux and Darwin `api` and `site-agent` builds set `metadata.Version` to |
| 178 | +`--version` and `metadata.BuildTime` to `YYYY-MM-DD HH:MM:SS`. Their `flow` |
| 179 | +builds set `metadata.Version` to `--version`, `metadata.BuildTime` to |
| 180 | +`YYYY-MM-DDTHH:MM:SSZ`, and `metadata.GitCommit` to `--short-sha`. Their full Go |
| 181 | +symbol paths live in the source manifest and are pinned by the command-contract |
| 182 | +test. Darwin builds omit only the Linux static-linker and stripping flags. |
| 183 | + |
| 184 | +`go version -m -json` exposes linker flags as one aggregate setting. Missing, |
| 185 | +extra, or reordered flags fail verification, unrelated build settings are |
| 186 | +ignored, and the last value wins if Go reports one setting key more than once. |
| 187 | +Every resolved `-X` value must also appear in the binary. The script does not |
| 188 | +maintain a second allowlist of `-X` destinations; the source manifest and its |
| 189 | +independent contract test own those destinations. |
| 190 | + |
| 191 | +## What verification checks |
| 192 | + |
| 193 | +The verifier starts from the checked-in source manifest and the identity the |
| 194 | +caller supplied. From there it checks: |
| 195 | + |
| 196 | +- the archive sidecar and exact archive inventory; |
| 197 | +- the byte-for-byte match between the external and embedded resolved manifest; |
| 198 | +- safe paths, regular-file types, exact modes, sizes, and checksums; |
| 199 | +- package import paths, `GOOS`, `GOARCH`, `CGO_ENABLED`, and linker flags from |
| 200 | + `go version -m -json`; |
| 201 | +- physical architecture and CGO-disabled Linux static linkage from `file -b`; |
| 202 | + and |
| 203 | +- every metadata value supplied through a `-X` linker flag. |
| 204 | + |
| 205 | +Go normally records `vcs.revision` in these binaries. Before compiling, the |
| 206 | +builder confirms that Git `HEAD` is the requested candidate and rejects a dirty |
| 207 | +checkout unless local validation explicitly passes `--allow-dirty`. The |
| 208 | +resolved manifest records that source check and uses `unavailable` only when Go |
| 209 | +omits the VCS setting. The standalone verifier checks the recorded candidate, |
| 210 | +build contract, binary metadata, checksums, and file inventory, but it cannot |
| 211 | +cryptographically prove which source tree produced the binaries. |
0 commit comments