From 19687b2d2d67f41599b43b9df574aa19955a3064 Mon Sep 17 00:00:00 2001 From: Colin Lacy Date: Mon, 9 Nov 2026 06:34:46 +0000 Subject: [PATCH 1/7] adds local build instructions --- CLI-IMPLEMENTATION.md | 14 +++++++++----- README.md | 30 ++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/CLI-IMPLEMENTATION.md b/CLI-IMPLEMENTATION.md index 2c66e01..7b1d2da 100644 --- a/CLI-IMPLEMENTATION.md +++ b/CLI-IMPLEMENTATION.md @@ -188,15 +188,19 @@ Windows and RPM channels follow after the initial package contract; do not adver - Tag rc-cli independently; pin component locks, Cobra, build Go, packaging tools, and Actions by full SHA. - For tag-triggered release builds, validate the vMAJOR.MINOR.PATCH tag (including prereleases) and - checked-out commit, then embed GITHUB_REF_NAME and GITHUB_SHA as linker values: + checked-out commit, then use the schema-aware build helper to embed GITHUB_REF_NAME, + GITHUB_SHA, and the verified core profile schema as linker values: ~~~sh - mkdir -p dist - go build -trimpath \ - -ldflags "-X main.version=${GITHUB_REF_NAME} -X main.commit=${GITHUB_SHA}" \ - -o dist/rc ./cmd/rc + go run ./scripts/build.go --output dist/rc \ + --version "${GITHUB_REF_NAME}" --commit "${GITHUB_SHA}" ~~~ + The helper downloads the pinned schema and verifies its SHA-256 before embedding it. + For an already downloaded artifact, add --core-schema /path/to/runtimeconditions.profile.schema.yaml; + the same checksum verification applies. Plain go build/go install omit the required schema value, + causing profile generation to fail with "installed profiler has no verified core profile schema". + Local builds use go run ./scripts/build.go --output ./rc, with optional version/commit flags. Verify rc version, rc --version, and rc version --output yaml report the release tag and source commit. Reject dev/unknown metadata in release artifacts; ordinary local builds retain those defaults. - Assemble locked upstream bytes and the verified CLI once; create a complete inventory and package without rebuilding. diff --git a/README.md b/README.md index 3350681..e98cb67 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,32 @@ # rc-cli The CLI entrypoint with Golang profiling support, extendable with language-specific profilers + +## Build locally + +From this repository's root, build the executable with the schema-aware helper: + +```sh +go run ./scripts/build.go --output ./rc +./rc --version +./rc profile generate --dir /path/to/go/workload --out profile.yaml +``` + +The helper downloads the pinned core profile schema, verifies its SHA-256, +and embeds it alongside the linked Go profiler. The resulting binary needs no +schema download or separate profiler installation at runtime. Analyzing a +workload requires a working Go toolchain and its binding dependencies. + +A plain `go build` or `go install` omits the schema linker value. Such a binary +can display help and version information, but profile generation fails with +`rc: installed profiler has no verified core profile schema`. +Rebuild it using the command above and run the resulting `./rc` executable. + +For a build using an already downloaded schema, supply the approved artifact: + +```sh +go run ./scripts/build.go --output ./rc \ + --core-schema /path/to/runtimeconditions.profile.schema.yaml +``` + +The local schema must match the helper's pinned checksum. Release builds also +pass `--version ` and `--commit ` to embed provenance. From 83952e05a89565c55e061ab4d21f5a70c99fd8aa Mon Sep 17 00:00:00 2001 From: Colin Lacy Date: Mon, 9 Nov 2026 07:24:19 +0000 Subject: [PATCH 2/7] adjusts plan for bindings integration --- CLI-IMPLEMENTATION.md | 134 ++++++++++++++++++++++-------------------- 1 file changed, 69 insertions(+), 65 deletions(-) diff --git a/CLI-IMPLEMENTATION.md b/CLI-IMPLEMENTATION.md index 7b1d2da..a5b1a7f 100644 --- a/CLI-IMPLEMENTATION.md +++ b/CLI-IMPLEMENTATION.md @@ -1,6 +1,7 @@ # CLI implementation plan -Status: proposed implementation sequence, 2026-11-07. +Status: implementation sequence, updated 2026-11-09. Native Go profiling is implemented; +binding library integration and distribution remain staged work. Repository: github.com/runtimeconditions/rc-cli; executable and command name: `rc`. System package and Homebrew formula name: `rc-cli`; releases use independent `vMAJOR.MINOR.PATCH` tags. This plan implements [CLI-PLANNING.md](../extensions/tooling/extension-bindings/CLI-PLANNING.md). @@ -12,7 +13,7 @@ These sibling links assume the repositories share a parent directory; published - This repository owns Cobra commands, binding adapters, profiler routing, diagnostics, and distribution. - The extensions repository owns resolver, normalizer, orchestrator, emitters, schemas, and binding release rules. - Each profiler repository owns its parser, binding inspection, semantic validation, runtime requirements, and releases. -- Ship the independently released Go profiler in every default rc-cli installation, with no separate plugin install. +- Link the independently versioned Go profiler into rc, with no separate profiler installation. - Optional languages use add-on system packages; their runtimes are not dependencies of the default installation. - Generated binding SDKs remain workload dependencies obtained through native language package managers. - Preserve YAML artifact contracts, stdout formats, filesystem behavior, and actual tool provenance. @@ -20,16 +21,16 @@ These sibling links assume the repositories share a parent directory; published ## 2. Architecture and repository layout -Use Cobra for native command discovery and help, and subprocesses for independently released tools. -Choose the standalone binding-tool boundary initially: the existing Go modules have local replace directives -and are not yet a published dependency set suitable for importing into this repository. -Package that backend privately as `rc-bindings`; never resolve another `rc` executable as its backend. -A future in-process adapter requires published modules and interface parity, without copying binding logic. +Use Cobra for native commands and direct Go library calls for binding operations and Go profiling. +Import the orchestrator, normalizer, and Go emitter as a resolvable, pinned dependency set. +Their current workspace replace directives do not provide downstream dependencies; prepare upstream +module versions before linking them here. Keep binding logic in extensions and retain its standalone CLI. +Subprocesses remain appropriate for optional language backends and native language toolchains. ~~~text cmd/rc/main.go # signals, streams, root execution, process exit internal/cli/{root,bindings,profile,plugins,doctor,version}.go -internal/bindings/adapter.go # translates explicit Cobra options to backend argv +internal/bindings/adapter.go # maps typed Cobra options to orchestrator.Execute internal/plugins/{descriptor,discover,compatibility}.go internal/process/runner.go # invocation, exit status, cancellation internal/install/layout.go # archive, Homebrew, and Linux installation roots @@ -52,39 +53,41 @@ rc --help rc version [--output yaml] rc completion rc bindings [binding flags] -rc profile generate -dir ./service -out profile.yaml -rc profile go generate -dir ./service -out profile.yaml -rc profile go validate-extensions -dir ./service +rc profile generate --dir ./service --out profile.yaml +rc profile go generate --dir ./service --out profile.yaml +rc profile go validate-extensions --dir ./service rc profile python profile generate --project ./service rc plugins list [--output yaml] rc doctor [--language go|python|java|...] [--output yaml] ~~~ -- `rc profile generate` and `rc profile go generate` invoke the same bundled Go backend and argv. -- After an explicit language selector, preserve all tokens verbatim, including flags, values, repetition, and `--`. -- Without a selector, current Go verbs generate, validate-extension, validate-extensions, and version select Go. - Leading backend flags also select Go; unknown leading names are language errors, with no silent Go fallback. -- Bare `rc profile` and sole `rc profile --help` show host routing help; `rc profile go --help` delegates to Go. +- `rc profile generate` and `rc profile go generate` use the same Cobra constructors and linked Go APIs. +- Register generate, validate-extension, validate-extensions, and version on both Go command routes. + Commands bind typed options; there is no manual Go argument parser or implicit verb for leading flags. +- Bare `rc profile`, `rc profile --help`, and `rc profile go --help` show native Cobra help. `rc --version` reports CLI version; `rc profile go --version` reports profiler version. -- Preserve each backend's native command tree. In particular, Python currently needs `profile generate`; +- After an optional language selector, preserve backend tokens, including values, repetition, and `--`. + Preserve each optional backend's native command tree. Python currently needs `profile generate`; do not invent interpreter-selection flags or strip that command prefix. - Reserve unqualified Go verb names so future language IDs cannot shadow them. - No root persistent flags in version 1. Scope configuration and diagnostics options to native commands; use `RC_CONFIG` for an explicitly selected host YAML configuration file. -- Use one raw Cobra profile dispatcher with DisableFlagParsing; do not register backend flags in Cobra. - Handle the host-help exception explicitly; avoid unknown-flag whitelists that can consume backend arguments. -- Cobra owns native help/completion. Complete installed language names; backend flag completion is deferred. +- Optional language branches may use DisableFlagParsing; Go commands use native Cobra flags. + Avoid unknown-flag whitelists that can consume optional backend arguments. +- Cobra owns native help/completion. Complete installed optional languages; their flag completion is deferred. Shell completion and top-level help must not scan workloads, contact networks, or run all discovered plugins. ## 4. Binding commands and migration Register all eight binding operations as Cobra children with command-specific flags and RunE handlers. -The installed adapter calls `rc-bindings bindings ...` with the caller's working directory. -Keep backend defaults by forwarding explicitly supplied flags only, except declared installed-tool defaults. +The adapter calls orchestrator.Execute with context, absolute caller directory, operation, Options, +and output streams. It does not parse arguments again or invoke a separate binding executable. +Initialize native-tool defaults with orchestrator.DefaultOptions, then apply Cobra values and declared +installed-library defaults. Preserve explicitly supplied values, including empty tool selections. Use repeatable string-array flags without comma splitting; preserve values containing spaces or commas. Keep --packages, --toolchain-lock, --tooling-dir, --cache, --work-dir, and --target within bindings. Mirror the current backend's resolution, input/output/report, native-tool, and explicit artifact-path options. -The backend remains authoritative for schema checks, project discovery, target ordering, and output safety. +The upstream library remains authoritative for schema checks, project discovery, target ordering, and output safety. | Operation | Required preservation | | --- | --- | @@ -96,34 +99,34 @@ The backend remains authoritative for schema checks, project discovery, target o | check | Verify and compare committed targets without updating them. | | update | Sole operation that synchronizes committed target trees after all candidates pass. | -Install a versioned binding-tool bundle containing the backend, schemas, normalizer, Go emitter, -and required non-language resources. Keep Python tooling optional and require it only for Python targets. -The bundle must run against an external project without an extensions source checkout. -Its release is an upstream prerequisite; missing released artifacts must remain an explicit dependency, -not trigger a build from arbitrary workspace source or an implicit network install. -The existing backend has workspace build fallbacks: the packaged release path must require explicit -installed tooling and fail if any component is absent, rather than relying on its build-time source path. -Reject symlinks resolving the backend to the common rc executable to prevent recursive invocation. -Preserve --toolchain-lock checks and report the actual executing backend and profiler identities. -Only select bundled tools automatically when they satisfy the requested lock; mismatch requires an error. - -After parity checks, migrate extensions' shared binding-tools action to a pinned rc-cli artifact, -and supply explicit backend, schema, normalizer/emitter, and profiler paths for its locked workflow. +Link the versioned binding modules and call exported normalizer and Go emitter functions directly. +Provide the approved schema bundle and required resources without requiring an extensions checkout. +Keep Python tooling optional and require it only for Python targets. Missing upstream module versions +or installed resources remain explicit prerequisites; do not build arbitrary workspace tools at runtime. +The initial upstream Execute extraction retains existing executable-based tools; direct Go execution, +resource packaging, and linked identities must be completed before installed CLI acceptance. +Preserve --toolchain-lock checks and report actual orchestrator, emitter, normalizer, and profiler identities. +Linked profiler identities use their module version and dependency checksum; executable identities retain +their artifact SHA-256. Update schemas and their consumers before emitting the new identity form. +A linked identity cannot satisfy an executable lock; every identity mismatch requires an error. + +After parity checks, extensions' shared binding-tools action may adopt a pinned rc-cli artifact +with explicit resource and tool selections matching its lock. Keep generation.py and publish.py as the owners of bot commits and publication preflight. -Remove the old user-facing cmd/rc entry point only after local helpers and both binding workflows use -the common CLI successfully. Retain the independent binding backend; this is not an orchestration rewrite. +Retain extensions' standalone cmd/rc entry point and generation workflows independently of rc-cli. +Standalone Go profiler files remain supported through explicit flags and environment selection. ## 5. Default Go profiler integration -Use the existing go-rc-profiler executable through argv; do not embed its parser or rebuild it in rc-cli. -Add a release component lock with exact release tag, source commit, per-platform archive URL, -archive SHA-256, extracted executable SHA-256, core-schema identity, and compatibility metadata. -Resolve and review actual release values when implementing; never select "latest" during a release build. -Stage the verified upstream executable unchanged under the package's private tool directory. -The Go profiler already embeds its approved schema; preserve that binary and schema provenance. -Default discovery chooses this installed copy, with no PATH dependency for locating the profiler itself. -Explicit user overrides are permitted for interactive work and must be visible in version/doctor output. -A CLI upgrade can change the pinned profiler version; the profiler keeps its independent release identity. +Require go-rc-profiler in go.mod at a pinned version and commit its dependency checksum in go.sum. +Native Cobra commands call extractor.ExtractGeneratedDir, extractor.MarshalProfileYAML, and +extensioncheck.ValidateImportedGoPackages through typed options. Report the actual linked module +version and checksum in version YAML. A CLI upgrade may change that independently versioned dependency. +The schema-aware build helper downloads and verifies the pinned core schema, then embeds it in rc. +No profiler executable, profiler PATH lookup, or runtime schema download is needed for Go profiling. +Component metadata must describe linked modules and the approved core schema. Existing standalone +profiler archive locks/staging helpers are legacy utilities, not the default Go runtime path. +Go extraction currently has no context parameter; check cancellation before and after that API call. A working Go toolchain and workload go.mod/dependencies remain prerequisites for analysis. Document the tested analyzer toolchain range independently of the Go version used to compile rc-cli. @@ -137,11 +140,10 @@ Go-only users install neither Python nor a JVM. Native SDK resolution remains th Use YAML descriptors with apiVersion, ID/language, version, protocol version, compatible CLI/schema ranges, runtime requirements, and an executable plus fixed argument prefix. Do not encode shell command strings. Define the strict schema and size limits before implementing discovery. -The initial Go compatibility adapter supplies descriptors from its locked package manifest and verifies -the actual binary digest and --version output; current go-rc-profiler has no capabilities handshake. +The built-in Go profiler uses linked dependency metadata and needs no add-on descriptor or handshake. Future add-ons expose a bounded, versioned YAML describe operation as well as their native argv entry point. -Lookup order: explicit configured override (including existing RC_GO_PROFILER_BIN), managed installed +Optional add-on lookup order: explicit configured override, managed installed descriptor, then an explicitly named PATH fallback. Do not enumerate or execute arbitrary rc-* files. Read descriptor directories from the installed package and explicit configuration; detect duplicate IDs, reject incompatible protocols, and show selected origin/version/path. Help/listing can inspect metadata only. @@ -152,12 +154,14 @@ cannot assume access to its distributions. Resolve that backend requirement befo Define optional packages rc-profiler-python/java/rust/node/ruby in the same OS channels as rc-cli. Add-ons declare their own compatible runtimes; SDK installation continues through pip, Maven, npm, etc. Initial CLI plugin commands list and inspect installations; package managers own installation and upgrades. +RC_GO_PROFILER_BIN and --go-profiler retain their standalone binding-tool meaning; they do not +replace the profiler linked into native rc profile commands. ## 7. Process behavior and diagnostics -Attach caller stdin/stdout/stderr, preserve cwd and environment, and invoke argument slices without a shell. -Never buffer/reformat profile YAML or print launcher progress on stdout. -Propagate backend exit codes; native CLI usage errors return 2 and launcher/setup failures return 1. +For subprocesses, attach caller streams, preserve cwd and environment, and use argument slices without a shell. +Linked APIs receive context and output writers; their YAML serialization stays with the owning library. +Keep progress off stdout. Propagate subprocess exit codes; native usage errors return 2 and other failures 1. Print each launcher error once; use Cobra SilenceErrors/SilenceUsage and explicitly show usage when appropriate. Native commands produce stable YAML only when requested; version YAML includes CLI and component identities. Doctor defaults to Go, checks the selected tools/runtime, and reports optional absent languages without @@ -167,8 +171,8 @@ remaining descendants. Preserve conventional signal exit results and avoid leaki ## 8. Distribution contents and installation -One rc-cli system package includes rc, the Go profiler, binding backend/resources, manifests, licenses, -notices, and static shell completions. Core generation tools are included; other language tooling is optional. +One rc-cli system package includes rc with its linked Go libraries, binding resources, manifests, +licenses, notices, and static shell completions. Other language tooling is optional. Use prefix-relative bin/rc, libexec/rc-cli tools, and share/rc/extension-bindings for archives/Homebrew. Use /usr/bin/rc, /usr/lib/rc-cli tools, and /usr/share/rc/extension-bindings in Debian packages. Resolve Homebrew symlinks to the formula prefix; never hard-code /opt/homebrew or /usr/local. @@ -181,7 +185,7 @@ The installed bundle runs without source checkouts and without bootstrap downloa Initial proposed matrix: macOS and Linux amd64/arm64; Debian 12/13 and Ubuntu 24.04/26.04 for APT. Declare tested macOS minimum versions and any libc requirements in platforms.yaml before publishing. -Validate the upstream profiler and binding-tool availability for every advertised target; no partial bundles. +Validate linked dependency compatibility and resource availability for every advertised target; no partial bundles. Windows and RPM channels follow after the initial package contract; do not advertise them as supported yet. ## 9. Build, release, and package channels @@ -203,10 +207,10 @@ Windows and RPM channels follow after the initial package contract; do not adver Local builds use go run ./scripts/build.go --output ./rc, with optional version/commit flags. Verify rc version, rc --version, and rc version --output yaml report the release tag and source commit. Reject dev/unknown metadata in release artifacts; ordinary local builds retain those defaults. -- Assemble locked upstream bytes and the verified CLI once; create a complete inventory and package without rebuilding. +- Build once from pinned modules and verified resources; inventory the resulting CLI and package without rebuilding. - Produce platform tar.gz archives, SHA256SUMS, component YAML, and .deb packages using pinned nFPM. Map Go amd64/arm64 to the corresponding Debian architecture fields and include package descriptions/licenses. -- Verify staged files against the inventory and compare Go binary bytes with the pinned profiler artifact. +- Verify staged files against the inventory and verify linked module versions/checksums in build metadata. - Keep native package dependencies explicit; no install hooks invoking pip/npm, curl scripts, or source builds. - Publish GitHub Release assets only after platform acceptance; promote verified bytes without rebuilding. - Publish Formula/rc-cli.rb into runtimeconditions/homebrew-tools using platform archive URLs and SHA-256. @@ -226,23 +230,23 @@ Windows and RPM channels follow after the initial package contract; do not adver | Step | Deliverable and completion evidence | | --- | --- | | 1. CLI foundation | Go module, Cobra root/version/completion, injectable dependencies; clean independent build. | -| 2. Go default | Locked Go bundle, raw profile dispatch, identical default/explicit Go results and native help. | +| 2. Go default | Pinned linked profiler, native Cobra commands, identical default/explicit Go results and verified core schema. | | 3. Add-on contract | Strict YAML schema, configuration/discovery, compatibility errors, plugin list and doctor. | -| 4. Binding adapter | Released standalone bundle prerequisite; all eight commands match the existing interface. | +| 4. Binding adapter | Versioned library dependencies, typed execution, installed resources and accurate identities; all eight commands preserve behavior. | | 5. Packaging | Relocatable archives, Homebrew formula, .deb installs/upgrades/removals on the stated matrix. | -| 6. Workflow migration | Extensions helpers use a pinned common CLI; old entry point is removed after parity. | +| 6. Workflow migration | Extensions helpers may use a pinned common CLI after parity; standalone generation remains supported. | | 7. Publication | Protected release, tap/APT promotion, documented installation, checksums and rollback procedure. | -Add focused tests for argv boundaries (spaces, repeated options, --, unknown flags), default Go aliases, -backend-owned help, error/exit propagation, cancellation, duplicate IDs, missing/incompatible tools, +Add focused tests for Cobra values and optional backend argv boundaries, default Go aliases, +native/optional help, error/exit propagation, cancellation, duplicate IDs, missing/incompatible tools, explicit override selection, and stdout byte preservation using controlled subprocess fixtures. -Compare generated Go profile bytes and negative-case behavior with direct go-rc-profiler invocation. +Compare generated Go profiles and negative-case behavior with the linked profiler API. Compare binding YAML, target defaults, lock failures, cleanup, atomic updates, and output paths with the old CLI. Install in clean VMs/containers from complete artifacts, away from checkout paths and without a profiler on PATH. Run a Go workload using rc-cli alone plus the documented Go toolchain and resolved workload bindings. Check that no Python/JVM is required; optional-language errors identify the add-on/runtime to install. Verify upgrade/removal ownership, Homebrew prefix handling, APT metadata/signatures, and retained component hashes. -A release is ready only after all eight binding operations and bundled Go profiling pass installed-package +A release is ready only after all eight binding operations and linked Go profiling pass installed-package acceptance, and both distribution channels install the same reviewed component versions. ## References From ab24887c2a73843d14bdbe706f9e385574bc88fc Mon Sep 17 00:00:00 2001 From: Colin Lacy Date: Wed, 11 Nov 2026 07:44:04 +0000 Subject: [PATCH 3/7] pins extension repo version --- .github/workflows/ci.yml | 104 +++++++++++++++++++++++++++++- distribution/components.lock.yaml | 3 + go.mod | 10 +++ go.sum | 6 ++ scripts/stage-go-profiler.go | 4 ++ 5 files changed, 125 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d84a2db..a3cab0f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,6 +55,75 @@ jobs: cache-dependency-path: | go.sum testdata/ci/go-project/go.sum + - name: Prepare pinned binding libraries and schema files + run: | + python3 -m venv "$RUNNER_TEMP/rc-ci-python" + "$RUNNER_TEMP/rc-ci-python/bin/python" -m pip install --disable-pip-version-check PyYAML==6.0.3 + "$RUNNER_TEMP/rc-ci-python/bin/python" - <<'PY' + import hashlib + import json + import os + from pathlib import Path + import re + import shutil + import subprocess + import yaml + + def output(*args): + return subprocess.check_output(args, text=True).strip() + + lock_path = Path("distribution/components.lock.yaml") + source = yaml.safe_load(lock_path.read_text())["extensions"] + revision, repository = source["commit"], source["repository"] + if not re.fullmatch(r"[0-9a-f]{40}", revision): + raise SystemExit("extensions commit must be a full SHA") + module_repository = repository.removeprefix("https://").removesuffix(".git") + requirements = { + item["Path"]: item["Version"] + for item in json.loads(output("go", "mod", "edit", "-json"))["Require"] + } + modules = [ + module_repository + "/tooling/extension-bindings/" + component + for component in ("normalizer", "emitters/go", "orchestrator") + ] + for module in modules: + version = requirements[module] + selected = json.loads(output("go", "list", "-m", "-json", module)) + if selected.get("Replace") or selected["Version"] != version: + raise SystemExit(f"{module}: selected dependency differs from pin") + downloaded = json.loads(output("go", "mod", "download", "-json", module + "@" + version)) + origin = downloaded.get("Origin", {}) + if origin.get("Hash") != revision or origin.get("URL", "").removesuffix(".git") != repository.removesuffix(".git"): + raise SystemExit(f"{module}: module origin differs from extensions pin") + subprocess.run(["go", "build", *modules], check=True) + + checkout = Path(os.environ["RUNNER_TEMP"]) / "rc-ci-extensions" + checkout.mkdir() + subprocess.run(["git", "init", "--quiet", str(checkout)], check=True) + subprocess.run(["git", "-C", str(checkout), "fetch", "--depth=1", repository, revision], check=True) + subprocess.run(["git", "-C", str(checkout), "checkout", "--quiet", "--detach", "FETCH_HEAD"], check=True) + if output("git", "-C", str(checkout), "rev-parse", "HEAD") != revision: + raise SystemExit("extensions checkout differs from pin") + + tooling = checkout / "tooling/extension-bindings" + schemas = sorted((tooling / "model").glob("*.schema.yaml")) + if not schemas: + raise SystemExit("extensions checkout has no binding schemas") + prefix = Path(os.environ["RUNNER_TEMP"]) / "rc-ci" + resources = prefix / "share/rc/extension-bindings" + (resources / "model").mkdir(parents=True) + checksums = [] + for schema in schemas: + if schema.is_symlink() or not schema.is_file(): + raise SystemExit(f"invalid schema source: {schema}") + relative = "model/" + schema.name + shutil.copyfile(schema, resources / relative) + checksums.append(hashlib.sha256(schema.read_bytes()).hexdigest() + " " + relative + "\n") + (resources / "SHA256SUMS").write_text("".join(checksums), encoding="utf-8") + metadata = prefix / "share/rc-cli" + metadata.mkdir(parents=True) + shutil.copyfile(lock_path, metadata / lock_path.name) + PY - name: Verify toolchain, platform, and formatting env: EXPECTED_GOOS: ${{ matrix.goos }} @@ -79,7 +148,7 @@ jobs: CI_COMMIT: ${{ github.sha }} run: | CGO_ENABLED=0 go run ./scripts/build.go \ - --output "$RUNNER_TEMP/rc-ci/rc" \ + --output "$RUNNER_TEMP/rc-ci/bin/rc" \ --version "$CI_VERSION" --commit "$CI_COMMIT" CGO_ENABLED=0 go build -trimpath -o "$RUNNER_TEMP/rc-ci-smoke" ./scripts/ci-smoke.go - name: Select the profiler's Go analysis toolchain @@ -93,9 +162,40 @@ jobs: run: | test "$(go env GOVERSION)" = "go$ANALYSIS_GO_VERSION" "$RUNNER_TEMP/rc-ci-smoke" \ - --rc "$RUNNER_TEMP/rc-ci/rc" \ + --rc "$RUNNER_TEMP/rc-ci/bin/rc" \ --fixture "$GITHUB_WORKSPACE/testdata/ci/go-project" \ --version ci --commit "$CI_COMMIT" \ --go-version "go$ANALYSIS_GO_VERSION" + - name: Verify staged binding resources against the pinned checkout + run: | + "$RUNNER_TEMP/rc-ci-python/bin/python" - <<'PY' + import hashlib + import os + from pathlib import Path + import subprocess + + temporary = Path(os.environ["RUNNER_TEMP"]) + checkout = temporary / "rc-ci-extensions" + resources = temporary / "rc-ci/share/rc/extension-bindings" + sources = sorted((checkout / "tooling/extension-bindings/model").glob("*.schema.yaml")) + expected = {} + for source in sources: + expected["model/" + source.name] = source.read_bytes() + actual = {path.relative_to(resources).as_posix() for path in resources.rglob("*") if path.is_file()} + if not expected or actual != set(expected) | {"SHA256SUMS"}: + raise SystemExit("staged schema inventory differs from checkout") + checksums = [] + for relative, data in sorted(expected.items()): + if (resources / relative).read_bytes() != data: + raise SystemExit(f"staged schema bytes differ: {relative}") + checksums.append(hashlib.sha256(data).hexdigest() + " " + relative + "\n") + if (resources / "SHA256SUMS").read_text() != "".join(checksums): + raise SystemExit("staged schema checksums differ") + if (temporary / "rc-ci/share/rc-cli/components.lock.yaml").read_bytes() != Path("distribution/components.lock.yaml").read_bytes(): + raise SystemExit("staged component lock differs") + if subprocess.check_output(["git", "-C", str(checkout), "status", "--porcelain"]): + raise SystemExit("extensions checkout was modified during the build") + print(f"Verified {len(expected)} unchanged schema files and their checksums") + PY - name: Verify tracked files were not rewritten run: git diff --exit-code -- . diff --git a/distribution/components.lock.yaml b/distribution/components.lock.yaml index 348b640..61b38ab 100644 --- a/distribution/components.lock.yaml +++ b/distribution/components.lock.yaml @@ -1,4 +1,7 @@ apiVersion: runtimeconditions.io/cli-components/v1alpha1 +extensions: + repository: https://github.com/runtimeconditions/extensions.git + commit: dd96ddaebb0d29d882212d6ccc10c130f3ff2ddf goProfiler: version: v0.6.0 commit: 4dc71655a74839fdee5fc5226071e1cda39c2234 diff --git a/go.mod b/go.mod index 6548fe1..2e9a857 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,9 @@ go 1.25.0 toolchain go1.26.5 require ( + github.com/runtimeconditions/extensions/tooling/extension-bindings/emitters/go v0.0.0-20261111072413-dd96ddaebb0d + github.com/runtimeconditions/extensions/tooling/extension-bindings/normalizer v0.0.0-20261111072413-dd96ddaebb0d + github.com/runtimeconditions/extensions/tooling/extension-bindings/orchestrator v0.0.0-20261111072413-dd96ddaebb0d github.com/runtimeconditions/go-rc-profiler v0.6.0 github.com/spf13/cobra v1.10.2 go.yaml.in/yaml/v3 v3.0.4 @@ -20,3 +23,10 @@ require ( golang.org/x/tools v0.37.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) + +// Upstream workspace manifests require these unpublished placeholders. +// Exclude them so graph checks use the exact source pins above. +exclude ( + github.com/runtimeconditions/extensions/tooling/extension-bindings/emitters/go v0.0.0 + github.com/runtimeconditions/extensions/tooling/extension-bindings/normalizer v0.0.0 +) diff --git a/go.sum b/go.sum index 47c964f..532fd7a 100644 --- a/go.sum +++ b/go.sum @@ -5,6 +5,12 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/runtimeconditions/extensions/tooling/extension-bindings/emitters/go v0.0.0-20261111072413-dd96ddaebb0d h1:/A1fXzKpDK0dxUqaecWhm8RCQJv1NIA8QTbPlboMmLw= +github.com/runtimeconditions/extensions/tooling/extension-bindings/emitters/go v0.0.0-20261111072413-dd96ddaebb0d/go.mod h1:qL1mMG1eemNwXsdC5pugLHPrxItlLoFomRcRgw8QxME= +github.com/runtimeconditions/extensions/tooling/extension-bindings/normalizer v0.0.0-20261111072413-dd96ddaebb0d h1:1kwe18vROpMNNXrTJghTsvJxJaCUcCsYjV6eib5oaFw= +github.com/runtimeconditions/extensions/tooling/extension-bindings/normalizer v0.0.0-20261111072413-dd96ddaebb0d/go.mod h1:cvsEa8JK0QyejCnMpu37euVabv3EYn29y5Z9Ep0bUQc= +github.com/runtimeconditions/extensions/tooling/extension-bindings/orchestrator v0.0.0-20261111072413-dd96ddaebb0d h1:inJH3dFBlD6flxgy8VANdvwPOgQrFv5vsDt1MF83ook= +github.com/runtimeconditions/extensions/tooling/extension-bindings/orchestrator v0.0.0-20261111072413-dd96ddaebb0d/go.mod h1:57G6oeRpB8qTceNnLYZmIavqukZsSwTK7P0EZMeJJxc= github.com/runtimeconditions/go-rc-profiler v0.6.0 h1:QSW5TCjZwLUYyxK2QlCwN6Sh1jICt6PbLbPVRJHDD+U= github.com/runtimeconditions/go-rc-profiler v0.6.0/go.mod h1:zQSOr50hlFxqJL2AaF3BpgTMbOlykey9OBa17E/jWFA= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= diff --git a/scripts/stage-go-profiler.go b/scripts/stage-go-profiler.go index d6f4c49..4754c66 100644 --- a/scripts/stage-go-profiler.go +++ b/scripts/stage-go-profiler.go @@ -32,6 +32,10 @@ type asset struct { type componentLock struct { APIVersion string `yaml:"apiVersion"` + Extensions struct { + Repository string `yaml:"repository"` + Commit string `yaml:"commit"` + } `yaml:"extensions"` GoProfiler struct { Version string `yaml:"version"` Commit string `yaml:"commit"` From 4010d2e590b460c4950cefc98f4ae7dc96bace03 Mon Sep 17 00:00:00 2001 From: Colin Lacy Date: Wed, 11 Nov 2026 08:37:47 +0000 Subject: [PATCH 4/7] adds bindings commands --- distribution/components.lock.yaml | 28 +-- go.mod | 2 +- go.sum | 4 +- internal/cli/bindings.go | 151 ++++++++++++++++ internal/cli/bindings_parity_test.go | 186 ++++++++++++++++++++ internal/cli/bindings_profiler_test.go | 146 +++++++++++++++ internal/cli/bindings_test.go | 234 +++++++++++++++++++++++++ internal/cli/root.go | 2 +- out.yaml | 14 ++ 9 files changed, 749 insertions(+), 18 deletions(-) create mode 100644 internal/cli/bindings.go create mode 100644 internal/cli/bindings_parity_test.go create mode 100644 internal/cli/bindings_profiler_test.go create mode 100644 internal/cli/bindings_test.go create mode 100644 out.yaml diff --git a/distribution/components.lock.yaml b/distribution/components.lock.yaml index 61b38ab..a5de6c9 100644 --- a/distribution/components.lock.yaml +++ b/distribution/components.lock.yaml @@ -3,8 +3,8 @@ extensions: repository: https://github.com/runtimeconditions/extensions.git commit: dd96ddaebb0d29d882212d6ccc10c130f3ff2ddf goProfiler: - version: v0.6.0 - commit: 4dc71655a74839fdee5fc5226071e1cda39c2234 + version: v0.6.1 + commit: c9fcd27dce1b29dfe3c024c9c87836059fd0854c protocol: go-profiler-argv/v1 coreSchema: version: 0.4.0 @@ -13,18 +13,18 @@ goProfiler: sha256: 96d430c7936fcf7334aa9613f63bb306592e07cf56fe4154af57933d2c1480e6 platforms: darwin/amd64: - url: https://github.com/runtimeconditions/go-rc-profiler/releases/download/v0.6.0/go-rc-profiler_v0.6.0_darwin_amd64.tar.gz - archiveSHA256: 1b1dcd4a3c441dba32996eab91a7414f715c11789c18f739c7054e1fa5f331c3 - executableSHA256: 681b6ca11c94caaec814b297b09cdb669f5ebb744b28f0089b70c80971c824f8 + url: https://github.com/runtimeconditions/go-rc-profiler/releases/download/v0.6.1/go-rc-profiler_v0.6.1_darwin_amd64.tar.gz + archiveSHA256: 6c4be33b807b4a05253643a652740b7aae388e0f2376c5052c5c5a140fb99986 + executableSHA256: 372e855da1ff3cfee0c7af8e0b98eefd6db456c8e1f6c81bc7f1fb24e790ac2b darwin/arm64: - url: https://github.com/runtimeconditions/go-rc-profiler/releases/download/v0.6.0/go-rc-profiler_v0.6.0_darwin_arm64.tar.gz - archiveSHA256: d82a66ef901e39695d4e563cee9cb61bb43a7917e6c7b94eacc35424dbff1e78 - executableSHA256: 2c19109db10dc8998fb87c84d04ec2b393eb3f28ca146a891776969bdfc615a9 + url: https://github.com/runtimeconditions/go-rc-profiler/releases/download/v0.6.1/go-rc-profiler_v0.6.1_darwin_arm64.tar.gz + archiveSHA256: cb1f7b845141a741a83ad8a7eb03234b0729ef5865cb222e5c814a02816b1676 + executableSHA256: 3b1c271c99eac17f9c732a83ca8d5ac552827f24cc7dfe58cd572ca521eeceb6 linux/amd64: - url: https://github.com/runtimeconditions/go-rc-profiler/releases/download/v0.6.0/go-rc-profiler_v0.6.0_linux_amd64.tar.gz - archiveSHA256: e35ef96832608445d0651118551e563c6ce2d19a8c239d172c301bd759bd5b75 - executableSHA256: 6bdb4b3f2e11a4f5cfe91793637c261cee2ce2c7739129ae88ce888cf4bd7c72 + url: https://github.com/runtimeconditions/go-rc-profiler/releases/download/v0.6.1/go-rc-profiler_v0.6.1_linux_amd64.tar.gz + archiveSHA256: 3580b761ed25afa86bb78730cc6996e4be29778508fd53c2edc9ab1acb01a429 + executableSHA256: bcaae5d7f5159b511edf62ff6bbc7e5ddda4e935eab442909583108798af3cc9 linux/arm64: - url: https://github.com/runtimeconditions/go-rc-profiler/releases/download/v0.6.0/go-rc-profiler_v0.6.0_linux_arm64.tar.gz - archiveSHA256: 7707f08046f6d0c95f19f6b308e3adfd1a48c94ae11059ce29374a68a3be7dac - executableSHA256: 2cc347109f96eca27bab201b2a50ab0088a4fe6c86a135dde53620977a23c3bf + url: https://github.com/runtimeconditions/go-rc-profiler/releases/download/v0.6.1/go-rc-profiler_v0.6.1_linux_arm64.tar.gz + archiveSHA256: 8885052621a46a5259bafcfb49f252cf3443f6cd8bf9431a0ee8cf151a6d4a67 + executableSHA256: e4175f9edb2e11439f1b1b79b270d5bd2d54ea4d150462ad855efb765938e33a diff --git a/go.mod b/go.mod index 2e9a857..76fbba8 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/runtimeconditions/extensions/tooling/extension-bindings/emitters/go v0.0.0-20261111072413-dd96ddaebb0d github.com/runtimeconditions/extensions/tooling/extension-bindings/normalizer v0.0.0-20261111072413-dd96ddaebb0d github.com/runtimeconditions/extensions/tooling/extension-bindings/orchestrator v0.0.0-20261111072413-dd96ddaebb0d - github.com/runtimeconditions/go-rc-profiler v0.6.0 + github.com/runtimeconditions/go-rc-profiler v0.6.1 github.com/spf13/cobra v1.10.2 go.yaml.in/yaml/v3 v3.0.4 golang.org/x/mod v0.28.0 diff --git a/go.sum b/go.sum index 532fd7a..9097594 100644 --- a/go.sum +++ b/go.sum @@ -11,8 +11,8 @@ github.com/runtimeconditions/extensions/tooling/extension-bindings/normalizer v0 github.com/runtimeconditions/extensions/tooling/extension-bindings/normalizer v0.0.0-20261111072413-dd96ddaebb0d/go.mod h1:cvsEa8JK0QyejCnMpu37euVabv3EYn29y5Z9Ep0bUQc= github.com/runtimeconditions/extensions/tooling/extension-bindings/orchestrator v0.0.0-20261111072413-dd96ddaebb0d h1:inJH3dFBlD6flxgy8VANdvwPOgQrFv5vsDt1MF83ook= github.com/runtimeconditions/extensions/tooling/extension-bindings/orchestrator v0.0.0-20261111072413-dd96ddaebb0d/go.mod h1:57G6oeRpB8qTceNnLYZmIavqukZsSwTK7P0EZMeJJxc= -github.com/runtimeconditions/go-rc-profiler v0.6.0 h1:QSW5TCjZwLUYyxK2QlCwN6Sh1jICt6PbLbPVRJHDD+U= -github.com/runtimeconditions/go-rc-profiler v0.6.0/go.mod h1:zQSOr50hlFxqJL2AaF3BpgTMbOlykey9OBa17E/jWFA= +github.com/runtimeconditions/go-rc-profiler v0.6.1 h1:lFatYU891EnZs5+N24HvzyLJ511+CrSKvg1iTOG4G0U= +github.com/runtimeconditions/go-rc-profiler v0.6.1/go.mod h1:zQSOr50hlFxqJL2AaF3BpgTMbOlykey9OBa17E/jWFA= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28= github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= diff --git a/internal/cli/bindings.go b/internal/cli/bindings.go new file mode 100644 index 0000000..88eaa96 --- /dev/null +++ b/internal/cli/bindings.go @@ -0,0 +1,151 @@ +package cli + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "runtime/debug" + + "github.com/runtimeconditions/extensions/tooling/extension-bindings/orchestrator" + "github.com/spf13/cobra" +) + +type bindingExecution func(context.Context, string, string, orchestrator.Options, io.Writer, io.Writer) error +type bindingProfiler func() (orchestrator.ProfilerIdentity, error) + +func newBindingsCommand() *cobra.Command { + return newBindingCommands(orchestrator.Execute, func() (orchestrator.ProfilerIdentity, error) { + info, _ := debug.ReadBuildInfo() + return bindingProfilerIdentity(info) + }) +} + +// Execution is injected only for command tests; production calls Execute directly. +func newBindingCommands(execute bindingExecution, profiler bindingProfiler) *cobra.Command { + parent := &cobra.Command{ + Use: "bindings", Short: "Resolve, generate, and verify extension bindings", + Args: usageArgs(cobra.NoArgs), ValidArgsFunction: cobra.NoFileCompletions, + RunE: func(cmd *cobra.Command, _ []string) error { return cmd.Help() }, + } + for _, operation := range []struct{ name, description string }{ + {"resolve", "Resolve extension definitions and dependency locks"}, + {"normalize", "Produce a normalized binding model"}, + {"generate", "Generate binding package trees"}, + {"verify", "Verify generated or committed binding packages"}, + {"package", "Verify and archive committed binding packages"}, + {"plan-release", "Compare packages and report release requirements"}, + {"check", "Regenerate, verify, and compare committed bindings"}, + {"update", "Generate, verify, and update committed bindings"}, + } { + name := operation.name + o := orchestrator.DefaultOptions() + o.UseGoLibraries = true + // Installed Go libraries replace standalone executable defaults, not flags. + o.Normalizer, o.GoEmitter, o.GoProfiler = "", "", "" + var targets, roots, overrides, catalogs []string + cmd := &cobra.Command{ + Use: name, Short: operation.description, + Args: usageArgs(cobra.NoArgs), ValidArgsFunction: cobra.NoFileCompletions, + RunE: func(cmd *cobra.Command, _ []string) error { + if err := cmd.Context().Err(); err != nil { + return err + } + if (name == "generate" || name == "package") && o.Output == "" { + return usageError{fmt.Errorf("%s requires --output", name)} + } + cwd, err := os.Getwd() + if err != nil { + return err + } + o.Targets, o.ExtensionRoots, o.Overrides, o.CatalogRoots = targets, roots, overrides, catalogs + if !cmd.Flags().Changed("tooling-dir") { + executable, err := os.Executable() + if err != nil { + return err + } + o.ToolingDir, err = installedBindingTooling(executable) + if err != nil { + return err + } + } + // Resolve and normalize do not record or require profiler provenance. + o.LinkedGoProfiler = nil + if name != "resolve" && name != "normalize" && !cmd.Flags().Changed("go-profiler") { + identity, err := profiler() + if err != nil { + return err + } + o.LinkedGoProfiler = &identity + } + return execute(cmd.Context(), cwd, name, o, cmd.OutOrStdout(), cmd.ErrOrStderr()) + }, + } + f := cmd.Flags() + f.StringVar(&o.Packages, "packages", "", "package catalog path") + f.StringVar(&o.ToolchainLock, "toolchain-lock", "", "toolchain lock path") + f.StringVar(&o.Cache, "cache", "", "explicit persistent extension cache") + f.StringVar(&o.WorkDir, "work-dir", "", "retain intermediates in this directory") + f.StringVar(&o.ToolingDir, "tooling-dir", "", "directory containing binding schemas and optional language resources") + f.StringArrayVar(&targets, "target", nil, "package-key:language; repeatable, defaults to all targets") + if name == "resolve" || name == "normalize" || name == "generate" || name == "package" || name == "check" || name == "update" { + f.StringArrayVar(&roots, "extension-root", nil, "exact root extension identifier; repeatable") + f.StringArrayVar(&overrides, "extension-override", nil, "identifier=local-path; repeatable, development only") + f.StringArrayVar(&catalogs, "catalog-root", nil, "local definition catalog; repeatable") + f.StringVar(&o.DependencyLock, "dependency-lock", "", "existing exact dependency lock") + } + if name == "resolve" { + f.StringVar(&o.ClosureOutput, "closure-output", "", "write semantic closure here") + } + if name == "resolve" || name == "normalize" { + f.StringVar(&o.DependencyLockOutput, "dependency-lock-output", "", "write resolved dependency lock here") + } + if name == "normalize" || name == "generate" || name == "package" || name == "plan-release" { + f.StringVar(&o.Output, "output", "", "requested output file or directory; required for generate and package") + } + if name == "verify" { + f.StringVar(&o.Input, "input", "", "generated tree root; defaults to committed targets") + } + if name == "verify" || name == "check" { + f.StringVar(&o.Report, "report", "", "write the YAML verification summary here") + } + f.StringVar(&o.CoreSchema, "core-schema", "", "explicit core profile schema file") + f.StringVar(&o.Go, "go", o.Go, "native Go command") + f.StringVar(&o.Python, "python", o.Python, "native Python interpreter") + f.StringVar(&o.Normalizer, "normalizer", o.Normalizer, "standalone normalizer override; conflicts with linked Go libraries") + f.StringVar(&o.GoEmitter, "go-emitter", o.GoEmitter, "standalone Go emitter override; conflicts with linked Go libraries") + f.StringVar(&o.GoProfiler, "go-profiler", o.GoProfiler, "standalone Go profiler; defaults to the linked profiler") + f.StringVar(&o.PythonProfiler, "python-profiler", o.PythonProfiler, "independently installed Python profiler") + f.StringVar(&o.PythonProfilerArtifact, "python-profiler-artifact", o.PythonProfilerArtifact, "installed Python profiler wheel for identity verification") + parent.AddCommand(cmd) + } + return parent +} + +func bindingProfilerIdentity(info *debug.BuildInfo) (orchestrator.ProfilerIdentity, error) { + if info != nil { + for _, dependency := range info.Deps { + if dependency.Path == goProfilerModule { + if dependency.Replace != nil { + return orchestrator.ProfilerIdentity{}, fmt.Errorf("linked Go profiler is replaced; use a verified published module or select --go-profiler") + } + return orchestrator.NewLinkedGoProfilerIdentity(dependency.Version, dependency.Sum) + } + } + } + return orchestrator.ProfilerIdentity{}, fmt.Errorf("linked Go profiler build metadata unavailable; build rc with its pinned dependencies or select --go-profiler") +} + +func installedBindingTooling(executable string) (string, error) { + path, err := filepath.EvalSymlinks(executable) + if err != nil { + return "", err + } + directory := filepath.Clean(filepath.Join(filepath.Dir(path), "..", "share", "rc", "extension-bindings")) + info, err := os.Stat(filepath.Join(directory, "model", "runtimeconditions.binding-model.schema.yaml")) + if err != nil || !info.Mode().IsRegular() { + return "", fmt.Errorf("binding schemas unavailable at %s; install the complete rc package or supply --tooling-dir", directory) + } + return directory, nil +} diff --git a/internal/cli/bindings_parity_test.go b/internal/cli/bindings_parity_test.go new file mode 100644 index 0000000..2b0d2e3 --- /dev/null +++ b/internal/cli/bindings_parity_test.go @@ -0,0 +1,186 @@ +package cli + +import ( + "bytes" + "context" + "io/fs" + "os" + "path/filepath" + "reflect" + "regexp" + "runtime" + "slices" + "strings" + "testing" + + "github.com/runtimeconditions/extensions/tooling/extension-bindings/orchestrator" + "github.com/spf13/pflag" +) + +func TestBindingsInterfaceMatchesPinnedUpstream(t *testing.T) { + var names, help bytes.Buffer + if err := orchestrator.Run(context.Background(), []string{"bindings", "--help"}, &names, &help); err != nil { + t.Fatal(err) + } + want := strings.Fields(strings.TrimPrefix(strings.TrimSpace(names.String()), "Commands:")) + var got []string + commands := newBindingsCommand().Commands() + for _, cmd := range commands { + got = append(got, cmd.Name()) + help.Reset() + if err := orchestrator.Run(context.Background(), []string{"bindings", cmd.Name(), "--help"}, &names, &help); err != nil { + t.Fatal(err) + } + var expectedFlags, actualFlags []string + for _, match := range regexp.MustCompile(`(?m)^ -([a-z][a-z0-9-]*)\b`).FindAllStringSubmatch(help.String(), -1) { + expectedFlags = append(expectedFlags, match[1]) + } + cmd.Flags().VisitAll(func(flag *pflag.Flag) { + if flag.Name != "help" { + actualFlags = append(actualFlags, flag.Name) + } + }) + slices.Sort(expectedFlags) + slices.Sort(actualFlags) + if !reflect.DeepEqual(actualFlags, expectedFlags) { + t.Fatalf("%s flags differ from upstream: %v; want %v", cmd.Name(), actualFlags, expectedFlags) + } + } + slices.Sort(want) + slices.Sort(got) + if !reflect.DeepEqual(got, want) { + t.Fatalf("commands differ from upstream: %v; want %v", got, want) + } +} + +func bindingParityFixture(t *testing.T) (orchestrator.Options, []string) { + t.Helper() + tooling := os.Getenv("RC_BINDINGS_TEST_TOOLING") + if tooling == "" { + tooling = filepath.Join(os.Getenv("RUNNER_TEMP"), "rc-ci", "share", "rc", "extension-bindings") + } + if _, err := os.Stat(filepath.Join(tooling, "model", "runtimeconditions.binding-model.schema.yaml")); err != nil { + t.Skip("requires CI-staged schemas or RC_BINDINGS_TEST_TOOLING pointing to the pinned YAML resources") + } + tooling, err := filepath.Abs(tooling) + if err != nil { + t.Fatal(err) + } + root := t.TempDir() + for _, dir := range []string{".git", "catalog"} { + if err := os.Mkdir(filepath.Join(root, dir), 0755); err != nil { + t.Fatal(err) + } + } + files := map[string]string{ + "catalog/runtimeconditions.extension.yaml": `apiVersion: runtimeconditions.io/v1alpha1 +kind: RuntimeConditionsExtensionDefinition +metadata: {id: 'https://bindings.example.test/parity:0.1.0', version: 0.1.0} +spec: + kinds: [{name: parity.service}] +`, + "packages.yaml": `apiVersion: runtimeconditions.io/package-catalog/v1alpha1 +kind: RuntimeConditionsPackageCatalog +repositoryUrl: https://github.com/example/bindings +packages: + fixture: + rootExtension: https://bindings.example.test/parity:0.1.0 + coreProfileSchemaVersion: 0.4.0 + languages: + go: + coordinate: example.test/bindings/fixture + name: fixture + version: 0.1.0 + sourceDirectory: bindings/fixture/go + languageVersion: ` + strings.TrimPrefix(runtime.Version(), "go") + ` + publicationMode: github-tag +`, + "toolchain.lock.yaml": "apiVersion: runtimeconditions.io/toolchain-lock/v1alpha1\nkind: RuntimeConditionsToolchainLock\nstatus: development\n", + "core.yaml": "$id: https://runtimeconditions.io/schemas/profile/0.4.0/runtimeconditions.profile.schema.yaml\ntype: object\n", + } + for name, content := range files { + if err := os.WriteFile(filepath.Join(root, name), []byte(content), 0644); err != nil { + t.Fatal(err) + } + } + o := orchestrator.DefaultOptions() + o.Normalizer, o.GoEmitter, o.GoProfiler, o.UseGoLibraries = "", "", "", true + o.Packages, o.ToolchainLock, o.CoreSchema, o.ToolingDir = filepath.Join(root, "packages.yaml"), filepath.Join(root, "toolchain.lock.yaml"), filepath.Join(root, "core.yaml"), tooling + return o, []string{"--packages", o.Packages, "--toolchain-lock", o.ToolchainLock, "--core-schema", o.CoreSchema, "--tooling-dir", tooling} +} + +func TestBindingsResultsMatchLibraryAndStandaloneResolution(t *testing.T) { + o, flags := bindingParityFixture(t) + cwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"resolve", "normalize", "generate", "verify"} { + t.Run(name, func(t *testing.T) { + selected := o + args := append([]string{"bindings", name}, flags...) + if name == "generate" || name == "verify" { + identity, _ := testBindingProfiler() + selected.LinkedGoProfiler = &identity + } + cliDirectory, apiDirectory := filepath.Join(t.TempDir(), "result"), filepath.Join(t.TempDir(), "result") + if name == "generate" { + selected.Output = apiDirectory + args = append(args, "--output", cliDirectory) + } + if name == "verify" { + generate := selected + generate.Output = apiDirectory + if err := orchestrator.Execute(context.Background(), cwd, "generate", generate, &bytes.Buffer{}, &bytes.Buffer{}); err != nil { + t.Fatal(err) + } + selected.Input = apiDirectory + args = append(args, "--input", apiDirectory) + } + var apiOut, apiErr bytes.Buffer + if err := orchestrator.Execute(context.Background(), cwd, name, selected, &apiOut, &apiErr); err != nil { + t.Fatal(err) + } + root, cliOut, cliErr := bindingTestRoot(orchestrator.Execute) + root.SetArgs(args) + if err := root.Execute(); err != nil || cliOut.String() != apiOut.String() || cliErr.String() != apiErr.String() { + t.Fatalf("%s result differs: %v\nCLI: %s %s\nAPI: %s %s", name, err, cliOut, cliErr, &apiOut, &apiErr) + } + if name == "generate" && !reflect.DeepEqual(bindingTree(t, cliDirectory), bindingTree(t, apiDirectory)) { + t.Fatal("generated package bytes differ from the library") + } + if name == "resolve" { + var standaloneOut, standaloneErr bytes.Buffer + if err := orchestrator.Run(context.Background(), args, &standaloneOut, &standaloneErr); err != nil || standaloneOut.String() != cliOut.String() || standaloneErr.String() != cliErr.String() { + t.Fatalf("standalone resolution differs: %v", err) + } + var diagnostics bytes.Buffer + code := Execute(context.Background(), args, BuildInfo{}, IOStreams{Out: failingWriter{}, ErrOut: &diagnostics}) + if code != 1 || strings.Count(diagnostics.String(), "rc: ") != 1 || !strings.Contains(diagnostics.String(), "output closed") { + t.Fatalf("output failure: exit %d, %s", code, &diagnostics) + } + } + }) + } +} + +func bindingTree(t *testing.T, root string) map[string]string { + t.Helper() + files := map[string]string{} + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + if err != nil || entry.IsDir() { + return err + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + relative, err := filepath.Rel(root, path) + files[relative] = string(data) + return err + }) + if err != nil { + t.Fatal(err) + } + return files +} diff --git a/internal/cli/bindings_profiler_test.go b/internal/cli/bindings_profiler_test.go new file mode 100644 index 0000000..12518fa --- /dev/null +++ b/internal/cli/bindings_profiler_test.go @@ -0,0 +1,146 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io/fs" + "net/url" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/runtimeconditions/extensions/tooling/extension-bindings/orchestrator" + "github.com/runtimeconditions/go-rc-profiler/extensioncheck" + "go.yaml.in/yaml/v3" + "golang.org/x/mod/module" + modzip "golang.org/x/mod/zip" +) + +func TestGeneratedLinkedMetadataAcceptedByProfiler(t *testing.T) { + o, _ := bindingParityFixture(t) + // Query the selected dependency, rather than inventing a profiler version or sum. + data, err := exec.Command(o.Go, "list", "-m", "-json", goProfilerModule).Output() + if err != nil { + t.Fatal(err) + } + var dependency struct { + Version, Sum string + Replace *json.RawMessage + } + if err := json.Unmarshal(data, &dependency); err != nil { + t.Fatal(err) + } + if dependency.Replace != nil { + t.Fatal("regression test requires the published profiler dependency") + } + identity, err := orchestrator.NewLinkedGoProfilerIdentity(dependency.Version, dependency.Sum) + if err != nil { + t.Fatal(err) + } + o.LinkedGoProfiler, o.Output = &identity, filepath.Join(t.TempDir(), "generated") + cwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := orchestrator.Execute(context.Background(), cwd, "generate", o, &bytes.Buffer{}, &bytes.Buffer{}); err != nil { + t.Fatal(err) + } + directory := filepath.Join(o.Output, "bindings", "fixture", "go") + data, err = os.ReadFile(filepath.Join(directory, "runtimeconditions.binding-release.yaml")) + if err != nil { + t.Fatal(err) + } + var release struct { + Package struct{ Coordinate, Version string } + Provenance struct{ Profiler orchestrator.ProfilerIdentity } + } + if err := yaml.Unmarshal(data, &release); err != nil { + t.Fatal(err) + } + if err := identity.Match(release.Provenance.Profiler); err != nil { + t.Fatal("generated linked profiler identity differs", err) + } + + // Install the generated package through Go's proxy protocol, without replaces. + proxy := t.TempDir() + path, err := module.EscapePath(release.Package.Coordinate) + if err != nil { + t.Fatal(err) + } + version, err := module.EscapeVersion(release.Package.Version) + if err != nil { + t.Fatal(err) + } + versionDirectory := filepath.Join(proxy, filepath.FromSlash(path), "@v") + if err := os.MkdirAll(versionDirectory, 0755); err != nil { + t.Fatal(err) + } + var archive bytes.Buffer + if err := modzip.CreateFromDir(&archive, module.Version{Path: release.Package.Coordinate, Version: release.Package.Version}, directory); err != nil { + t.Fatal(err) + } + goMod, err := os.ReadFile(filepath.Join(directory, "go.mod")) + if err != nil { + t.Fatal(err) + } + info, err := json.Marshal(struct{ Version, Time string }{release.Package.Version, "2026-01-01T00:00:00Z"}) + if err != nil { + t.Fatal(err) + } + for name, contents := range map[string][]byte{version + ".zip": archive.Bytes(), version + ".mod": goMod, version + ".info": info, "list": []byte(release.Package.Version + "\n")} { + if err := os.WriteFile(filepath.Join(versionDirectory, name), contents, 0644); err != nil { + t.Fatal(err) + } + } + consumer := t.TempDir() + for name, contents := range map[string]string{ + "go.mod": fmt.Sprintf("module example.test/consumer\n\ngo %s\n\nrequire %s %s\n", strings.TrimPrefix(runtime.Version(), "go"), release.Package.Coordinate, release.Package.Version), + "main.go": fmt.Sprintf("package main\nimport _ %q\nfunc main() {}\n", release.Package.Coordinate), + } { + if err := os.WriteFile(filepath.Join(consumer, name), []byte(contents), 0644); err != nil { + t.Fatal(err) + } + } + cache, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + // Go extracts read-only module directories; make only this test cache writable + // before testing.TempDir removes it. + t.Cleanup(func() { + err := filepath.WalkDir(cache, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + return os.Chmod(path, 0755) + } + return nil + }) + if err != nil { + t.Error("make temporary module cache removable", err) + } + }) + t.Setenv("GOMODCACHE", cache) + t.Setenv("GOPATH", t.TempDir()) + t.Setenv("GOPROXY", (&url.URL{Scheme: "file", Path: filepath.ToSlash(proxy)}).String()) + t.Setenv("GOSUMDB", "off") // Only this test's private module proxy is available. + t.Setenv("GOWORK", "off") + t.Setenv("GOTOOLCHAIN", "local") + t.Setenv("GOFLAGS", "-mod=readonly -buildvcs=false") + packages, err := extensioncheck.ValidateImportedGoPackages(context.Background(), consumer, []string{release.Package.Coordinate}) + if err != nil { + t.Fatal("linked profiler rejected generated binding metadata", err) + } + if len(packages) != 1 || packages[0].ImportPath != release.Package.Coordinate || packages[0].Version != release.Package.Version || !strings.HasPrefix(packages[0].Dir, cache+string(filepath.Separator)) { + t.Fatalf("profiler did not validate the installed versioned module: %+v", packages) + } + if _, err := os.Stat(filepath.Join(consumer, "go.sum")); err != nil { + t.Fatal("consumer did not retain module checksums", err) + } +} diff --git a/internal/cli/bindings_test.go b/internal/cli/bindings_test.go new file mode 100644 index 0000000..020d672 --- /dev/null +++ b/internal/cli/bindings_test.go @@ -0,0 +1,234 @@ +package cli + +import ( + "bytes" + "context" + "errors" + "io" + "os" + "path/filepath" + "reflect" + "runtime/debug" + "strings" + "testing" + + "github.com/runtimeconditions/extensions/tooling/extension-bindings/orchestrator" + "github.com/spf13/cobra" +) + +var bindingOperations = []string{"resolve", "normalize", "generate", "verify", "package", "plan-release", "check", "update"} + +func testBindingProfiler() (orchestrator.ProfilerIdentity, error) { + return orchestrator.NewLinkedGoProfilerIdentity("v0.6.0", "h1:"+strings.Repeat("A", 43)+"=") +} + +func bindingTestRoot(execute bindingExecution) (*cobra.Command, *bytes.Buffer, *bytes.Buffer) { + var out, errOut bytes.Buffer + root := &cobra.Command{Use: "rc", SilenceUsage: true, SilenceErrors: true} + root.SetOut(&out) + root.SetErr(&errOut) + root.AddCommand(newBindingCommands(execute, testBindingProfiler)) + return root, &out, &errOut +} + +func TestBindingsHelpCompletionAndUsage(t *testing.T) { + t.Setenv("PATH", "") + t.Setenv("RC_GO_PROFILER_BIN", "/must/not/run") + for _, name := range append([]string{""}, bindingOperations...) { + args := []string{"bindings", "--help"} + if name != "" { + args = []string{"bindings", name, "--help"} + } + if code, out, errOut := invoke(t, args...); code != 0 || !strings.Contains(out, "Usage:") || errOut != "" { + t.Fatalf("help %s: %d %q %q", name, code, out, errOut) + } + } + if code, out, _ := invoke(t, "__complete", "bindings", ""); code != 0 || !strings.Contains(out, "plan-release\t") { + t.Fatalf("binding completion: %d %q", code, out) + } + for _, args := range [][]string{{"missing"}, {"resolve", "extra"}, {"verify", "--closure-output", "x"}, {"generate"}, {"package", "--output", ""}} { + code, out, errOut := invoke(t, append([]string{"bindings"}, args...)...) + if code != 2 || out != "" || strings.Count(errOut, "rc: ") != 1 || !strings.Contains(errOut, "Usage:") { + t.Fatalf("usage %v: %d %q %q", args, code, out, errOut) + } + } +} + +func TestBindingsMapEveryCommandAndFlag(t *testing.T) { + fields := [][2]string{{"packages", "Packages"}, {"toolchain-lock", "ToolchainLock"}, {"tooling-dir", "ToolingDir"}, {"cache", "Cache"}, {"work-dir", "WorkDir"}, {"dependency-lock", "DependencyLock"}, {"closure-output", "ClosureOutput"}, {"dependency-lock-output", "DependencyLockOutput"}, {"output", "Output"}, {"input", "Input"}, {"report", "Report"}, {"core-schema", "CoreSchema"}, {"go", "Go"}, {"python", "Python"}, {"normalizer", "Normalizer"}, {"go-emitter", "GoEmitter"}, {"go-profiler", "GoProfiler"}, {"python-profiler", "PythonProfiler"}, {"python-profiler-artifact", "PythonProfilerArtifact"}} + for _, operation := range bindingOperations { + t.Run(operation, func(t *testing.T) { + want := orchestrator.DefaultOptions() + want.Normalizer, want.GoEmitter, want.GoProfiler, want.UseGoLibraries = "", "", "", true + calls := 0 + ctx := context.WithValue(context.Background(), struct{}{}, "sentinel") + root, out, errOut := bindingTestRoot(func(gotCtx context.Context, cwd, name string, got orchestrator.Options, stdout, stderr io.Writer) error { + calls++ + if gotCtx != ctx || name != operation || !filepath.IsAbs(cwd) || !reflect.DeepEqual(got, want) { + t.Fatalf("dispatch %s: cwd=%q options=%+v; want %+v", name, cwd, got, want) + } + _, _ = io.WriteString(stdout, "data") + _, _ = io.WriteString(stderr, "diagnostic") + return nil + }) + cmd, _, _ := root.Find([]string{"bindings", operation}) + args := []string{"bindings", operation} + for _, field := range fields { + if cmd.Flags().Lookup(field[0]) != nil { + value := field[0] + " with spaces,commas" + args = append(args, "--"+field[0], value) + reflect.ValueOf(&want).Elem().FieldByName(field[1]).SetString(value) + } + } + for _, field := range [][2]string{{"target", "Targets"}, {"extension-root", "ExtensionRoots"}, {"extension-override", "Overrides"}, {"catalog-root", "CatalogRoots"}} { + if cmd.Flags().Lookup(field[0]) != nil { + values := []string{"one with spaces,commas", "two"} + args = append(args, "--"+field[0], values[0], "--"+field[0], values[1]) + destination := reflect.ValueOf(&want).Elem().FieldByName(field[1]) + destination.Set(reflect.ValueOf(values).Convert(destination.Type())) + } + } + root.SetArgs(append(args, "--")) + if err := root.ExecuteContext(ctx); err != nil || calls != 1 || out.String() != "data" || errOut.String() != "diagnostic" { + t.Fatalf("dispatch failed: %v calls=%d stdout=%q stderr=%q", err, calls, out, errOut) + } + }) + } +} + +func TestBindingsExplicitEmptyToolsAndIsolation(t *testing.T) { + root, _, _ := bindingTestRoot(func(_ context.Context, _, _ string, o orchestrator.Options, _, _ io.Writer) error { + if o.Go != "" || o.Python != "" || o.GoProfiler != "" || o.LinkedGoProfiler != nil || o.ToolingDir != "" { + t.Fatalf("explicit empty selections replaced: %+v", o) + } + return nil + }) + root.SetArgs([]string{"bindings", "generate", "--output", "result", "--tooling-dir=", "--go=", "--python=", "--go-profiler="}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + generate, _, _ := root.Find([]string{"bindings", "generate"}) + verify, _, _ := root.Find([]string{"bindings", "verify"}) + if generate == verify || verify.Flags().Changed("go") { + t.Fatal("binding commands share flag state") + } +} + +func TestBindingsCancellationAndLibraryErrors(t *testing.T) { + want := errors.New("library failure") + calls := 0 + execute := func(context.Context, string, string, orchestrator.Options, io.Writer, io.Writer) error { + calls++ + return want + } + root, _, _ := bindingTestRoot(execute) + root.SetArgs([]string{"bindings", "resolve", "--tooling-dir=chosen"}) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := root.ExecuteContext(ctx); !errors.Is(err, context.Canceled) || calls != 0 { + t.Fatalf("canceled dispatch: %v calls=%d", err, calls) + } + root, _, _ = bindingTestRoot(execute) + root.SetArgs([]string{"bindings", "resolve", "--tooling-dir=chosen"}) + if err := root.ExecuteContext(context.Background()); !errors.Is(err, want) || calls != 1 { + t.Fatalf("library error: %v calls=%d", err, calls) + } +} + +func TestBindingsProfilerMetadata(t *testing.T) { + valid := debug.Module{Path: goProfilerModule, Version: "v0.6.0", Sum: "h1:" + strings.Repeat("A", 43) + "="} + if got, err := bindingProfilerIdentity(&debug.BuildInfo{Deps: []*debug.Module{&valid}}); err != nil || got.ModuleSum != valid.Sum || got.Version != valid.Version { + t.Fatalf("linked profiler: %+v %v", got, err) + } + for _, field := range []string{"absent", "replacement", "version", "checksum"} { + module := valid + info := &debug.BuildInfo{Deps: []*debug.Module{&module}} + switch field { + case "absent": + info = nil + case "replacement": + module.Replace = &valid + case "version": + module.Version = "(devel)" + case "checksum": + module.Sum = "" + } + if _, err := bindingProfilerIdentity(info); err == nil { + t.Fatalf("accepted profiler %s", field) + } + } +} + +func TestBindingsInstalledResourceLookup(t *testing.T) { + prefix := t.TempDir() + executable := filepath.Join(prefix, "bin", "rc") + resources := filepath.Join(prefix, "share", "rc", "extension-bindings") + for _, path := range []string{filepath.Dir(executable), filepath.Join(resources, "model")} { + if err := os.MkdirAll(path, 0755); err != nil { + t.Fatal(err) + } + } + model := filepath.Join(resources, "model", "runtimeconditions.binding-model.schema.yaml") + for _, path := range []string{executable, model} { + if err := os.WriteFile(path, nil, 0644); err != nil { + t.Fatal(err) + } + } + link := filepath.Join(t.TempDir(), "rc") + if err := os.Symlink(executable, link); err != nil { + t.Fatal(err) + } + resources, _ = filepath.EvalSymlinks(resources) + if got, err := installedBindingTooling(link); err != nil || got != resources { + t.Fatalf("installed schema lookup: %q %v", got, err) + } + if err := os.Remove(model); err != nil { + t.Fatal(err) + } + if _, err := installedBindingTooling(executable); err == nil || !strings.Contains(err.Error(), "--tooling-dir") { + t.Fatalf("missing schemas: %v", err) + } +} + +func TestBindingsDefaultsMatchUpstreamWithDeclaredLibraryOverrides(t *testing.T) { + for key, value := range map[string]string{"RC_BINDINGS_GO": "native-go", "RC_BINDINGS_PYTHON": "native-python", "RC_BINDINGS_NORMALIZER": "external-normalizer", "RC_BINDINGS_GO_EMITTER": "external-emitter", "RC_GO_PROFILER_BIN": "external-profiler", "RC_PYTHON_PROFILER_BIN": "python-profiler", "RC_PYTHON_PROFILER_ARTIFACT": "profiler.whl"} { + t.Setenv(key, value) + } + for _, name := range bindingOperations { + want := orchestrator.DefaultOptions() + want.Normalizer, want.GoEmitter, want.GoProfiler, want.UseGoLibraries = "", "", "", true + want.ToolingDir = "selected" + if name != "resolve" && name != "normalize" { + identity, _ := testBindingProfiler() + want.LinkedGoProfiler = &identity + } + args := []string{"bindings", name, "--tooling-dir=selected"} + if name == "generate" || name == "package" { + want.Output = "result" + args = append(args, "--output=result") + } + root, _, _ := bindingTestRoot(func(_ context.Context, _, _ string, got orchestrator.Options, _, _ io.Writer) error { + if !reflect.DeepEqual(got, want) { + t.Fatalf("%s defaults differ: %+v; want %+v", name, got, want) + } + return nil + }) + root.SetArgs(args) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + } +} + +func TestBindingsProfilerFailureDoesNotSelectStandaloneTool(t *testing.T) { + want := errors.New("unverified profiler metadata") + cmd := newBindingCommands(func(context.Context, string, string, orchestrator.Options, io.Writer, io.Writer) error { + t.Fatal("executed after profiler identity failed") + return nil + }, func() (orchestrator.ProfilerIdentity, error) { return orchestrator.ProfilerIdentity{}, want }) + cmd.SilenceErrors, cmd.SilenceUsage = true, true + cmd.SetArgs([]string{"generate", "--output=result", "--tooling-dir=selected"}) + if err := cmd.Execute(); !errors.Is(err, want) { + t.Fatalf("profiler error replaced: %v", err) + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 7fc2570..a3fc354 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -95,7 +95,7 @@ func NewRootCommand(build BuildInfo, streams IOStreams) *cobra.Command { root.SetErr(streams.ErrOut) root.SetVersionTemplate("{{printf \"%s %s (%s)\\n\" .Name .Version (index .Annotations \"commit\")}}") root.SetFlagErrorFunc(func(_ *cobra.Command, err error) error { return usageError{err} }) - root.AddCommand(newVersionCommand(build), newCompletionCommand(), newProfileCommand()) + root.AddCommand(newVersionCommand(build), newCompletionCommand(), newProfileCommand(), newBindingsCommand()) root.InitDefaultHelpCmd() for _, cmd := range root.Commands() { if cmd.Name() == "help" { diff --git a/out.yaml b/out.yaml new file mode 100644 index 0000000..08c4235 --- /dev/null +++ b/out.yaml @@ -0,0 +1,14 @@ +apiVersion: runtimeconditions.io/v1alpha1 +kind: RuntimeConditionsProfile +metadata: + name: go-project +workload: + uri: example.com/rc-cli-ci-fixture + version: dev +extensions: + - https://runtimeconditions.io/extensions/common-integrations/v1alpha1/runtimeconditions.extension.yaml +conditions: + - interface: + engine: redis + type: key_value + kind: cache From c3a600fc83984903bc50ed7d1d3dc20c342a6bbf Mon Sep 17 00:00:00 2001 From: Colin Lacy Date: Wed, 11 Nov 2026 08:38:20 +0000 Subject: [PATCH 5/7] removes test file --- out.yaml | 14 -------------- 1 file changed, 14 deletions(-) delete mode 100644 out.yaml diff --git a/out.yaml b/out.yaml deleted file mode 100644 index 08c4235..0000000 --- a/out.yaml +++ /dev/null @@ -1,14 +0,0 @@ -apiVersion: runtimeconditions.io/v1alpha1 -kind: RuntimeConditionsProfile -metadata: - name: go-project -workload: - uri: example.com/rc-cli-ci-fixture - version: dev -extensions: - - https://runtimeconditions.io/extensions/common-integrations/v1alpha1/runtimeconditions.extension.yaml -conditions: - - interface: - engine: redis - type: key_value - kind: cache From 5fbb2030d78504a4e2fbfe3cb1f38295b6baaa77 Mon Sep 17 00:00:00 2001 From: Colin Lacy Date: Wed, 11 Nov 2026 08:58:20 +0000 Subject: [PATCH 6/7] adds ci --- .github/workflows/ci.yml | 15 ++ CLI-IMPLEMENTATION.md | 72 +++++-- README.md | 71 ++++++- scripts/ci-bindings-smoke.py | 183 ++++++++++++++++++ .../bindings/runtimeconditions.extension.yaml | 35 ++++ 5 files changed, 355 insertions(+), 21 deletions(-) create mode 100644 scripts/ci-bindings-smoke.py create mode 100644 testdata/ci/bindings/runtimeconditions.extension.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a3cab0f..651f6b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,6 +67,7 @@ jobs: import re import shutil import subprocess + import urllib.request import yaml def output(*args): @@ -123,6 +124,12 @@ jobs: metadata = prefix / "share/rc-cli" metadata.mkdir(parents=True) shutil.copyfile(lock_path, metadata / lock_path.name) + core = yaml.safe_load(lock_path.read_text())["goProfiler"]["coreSchema"] + with urllib.request.urlopen(core["url"], timeout=60) as response: + data = response.read(64 * 1024 * 1024 + 1) + if hashlib.sha256(data).hexdigest() != core["sha256"]: + raise SystemExit("core schema checksum differs from the component lock") + (Path(os.environ["RUNNER_TEMP"]) / "rc-ci-core.schema.yaml").write_bytes(data) PY - name: Verify toolchain, platform, and formatting env: @@ -149,6 +156,7 @@ jobs: run: | CGO_ENABLED=0 go run ./scripts/build.go \ --output "$RUNNER_TEMP/rc-ci/bin/rc" \ + --core-schema "$RUNNER_TEMP/rc-ci-core.schema.yaml" \ --version "$CI_VERSION" --commit "$CI_COMMIT" CGO_ENABLED=0 go build -trimpath -o "$RUNNER_TEMP/rc-ci-smoke" ./scripts/ci-smoke.go - name: Select the profiler's Go analysis toolchain @@ -166,6 +174,13 @@ jobs: --fixture "$GITHUB_WORKSPACE/testdata/ci/go-project" \ --version ci --commit "$CI_COMMIT" \ --go-version "go$ANALYSIS_GO_VERSION" + - name: Verify generated bindings and profiling with the relocated CLI + run: | + "$RUNNER_TEMP/rc-ci-python/bin/python" scripts/ci-bindings-smoke.py \ + --prefix "$RUNNER_TEMP/rc-ci" \ + --fixture "$GITHUB_WORKSPACE/testdata/ci/bindings/runtimeconditions.extension.yaml" \ + --core-schema "$RUNNER_TEMP/rc-ci-core.schema.yaml" \ + --go-version "go$ANALYSIS_GO_VERSION" - name: Verify staged binding resources against the pinned checkout run: | "$RUNNER_TEMP/rc-ci-python/bin/python" - <<'PY' diff --git a/CLI-IMPLEMENTATION.md b/CLI-IMPLEMENTATION.md index a5b1a7f..72b17fd 100644 --- a/CLI-IMPLEMENTATION.md +++ b/CLI-IMPLEMENTATION.md @@ -1,7 +1,8 @@ # CLI implementation plan -Status: implementation sequence, updated 2026-11-09. Native Go profiling is implemented; -binding library integration and distribution remain staged work. +Status: implementation sequence, updated 2026-11-11. Native Go profiling and binding +commands are implemented, with CI coverage for the relocated CLI and shared YAML resources. +System-package distribution, add-ons, and upstream workflow migration remain planned. Repository: github.com/runtimeconditions/rc-cli; executable and command name: `rc`. System package and Homebrew formula name: `rc-cli`; releases use independent `vMAJOR.MINOR.PATCH` tags. This plan implements [CLI-PLANNING.md](../extensions/tooling/extension-bindings/CLI-PLANNING.md). @@ -23,14 +24,16 @@ These sibling links assume the repositories share a parent directory; published Use Cobra for native commands and direct Go library calls for binding operations and Go profiling. Import the orchestrator, normalizer, and Go emitter as a resolvable, pinned dependency set. -Their current workspace replace directives do not provide downstream dependencies; prepare upstream -module versions before linking them here. Keep binding logic in extensions and retain its standalone CLI. +Pin the three nested binding modules to the same full extensions commit recorded in +distribution/components.lock.yaml. go.mod explicitly requires those versions and excludes +the normalizer/emitter v0.0.0 workspace placeholders; local replace directives are not +propagated into rc-cli. Keep binding logic in extensions and retain its standalone CLI. Subprocesses remain appropriate for optional language backends and native language toolchains. ~~~text cmd/rc/main.go # signals, streams, root execution, process exit -internal/cli/{root,bindings,profile,plugins,doctor,version}.go -internal/bindings/adapter.go # maps typed Cobra options to orchestrator.Execute +internal/cli/{root,profile,plugins,doctor,version}.go +internal/cli/bindings.go # Cobra options and direct orchestrator.Execute calls internal/plugins/{descriptor,discover,compatibility}.go internal/process/runner.go # invocation, exit status, cancellation internal/install/layout.go # archive, Homebrew, and Linux installation roots @@ -79,9 +82,12 @@ rc doctor [--language go|python|java|...] [--output yaml] ## 4. Binding commands and migration -Register all eight binding operations as Cobra children with command-specific flags and RunE handlers. -The adapter calls orchestrator.Execute with context, absolute caller directory, operation, Options, -and output streams. It does not parse arguments again or invoke a separate binding executable. +All eight binding operations are Cobra children with command-specific flags and RunE handlers. +bindings.go calls orchestrator.Execute with context, absolute caller directory, operation, +Options, and output streams. There is no separate adapter package or second argument parser. +Cobra maintains the command interface in rc-cli; the shared library owns operation behavior. +Parity tests compare command and flag names against upstream Run help, defaults against +DefaultOptions with declared linked-library overrides, and results against Execute. Initialize native-tool defaults with orchestrator.DefaultOptions, then apply Cobra values and declared installed-library defaults. Preserve explicitly supplied values, including empty tool selections. Use repeatable string-array flags without comma splitting; preserve values containing spaces or commas. @@ -103,8 +109,11 @@ Link the versioned binding modules and call exported normalizer and Go emitter f Provide the approved schema bundle and required resources without requiring an extensions checkout. Keep Python tooling optional and require it only for Python targets. Missing upstream module versions or installed resources remain explicit prerequisites; do not build arbitrary workspace tools at runtime. -The initial upstream Execute extraction retains existing executable-based tools; direct Go execution, -resource packaging, and linked identities must be completed before installed CLI acceptance. +rc-cli selects direct Go normalizer/emitter execution and linked profiler identities by default. +The normalizer/emitter executable flags are retained for interface parity; nonempty selections +conflict with the linked-library mode. Explicit --go-profiler selects a standalone artifact, +including preserving an explicitly empty selection as an error when an artifact is required. +Help and completion do not perform resource discovery or require a profiler identity. Preserve --toolchain-lock checks and report actual orchestrator, emitter, normalizer, and profiler identities. Linked profiler identities use their module version and dependency checksum; executable identities retain their artifact SHA-256. Update schemas and their consumers before emitting the new identity form. @@ -114,7 +123,24 @@ After parity checks, extensions' shared binding-tools action may adopt a pinned with explicit resource and tool selections matching its lock. Keep generation.py and publish.py as the owners of bot commits and publication preflight. Retain extensions' standalone cmd/rc entry point and generation workflows independently of rc-cli. -Standalone Go profiler files remain supported through explicit flags and environment selection. +Standalone Go profiler files remain supported by rc bindings through --go-profiler. +The independent extensions CLI retains its existing flag and environment selections. + +CI checks out the locked extensions revision during the build and verifies that all three +Go dependency origins match it. It copies model/*.schema.yaml into the staged +share/rc/extension-bindings/model directory and generates checksums in the build output. +Schemas are not a Go module or a separately versioned archive. Installed lookup resolves +the executable's symlinks before finding this directory; --tooling-dir overrides it. +Local executable-only builds require --tooling-dir for binding commands. + +CI downloads the core schema using its component-lock URL and verifies its checksum before +passing the same file to the build helper and binding smoke test. Go profiling embeds that +verified core schema; binding normalization accepts --core-schema or upstream resolution. +Offline binding operations also need local definitions and resolved native dependencies. +Linked normalizer/emitter provenance records each component's name/version and the host +executable SHA-256. Rebuilding rc therefore changes these identities and the model digest. +The linked Go profiler uses its independently versioned module and Go dependency checksum. +go-rc-profiler v0.6.1 supports this identity and the existing standalone artifact form. ## 5. Default Go profiler integration @@ -154,8 +180,9 @@ cannot assume access to its distributions. Resolve that backend requirement befo Define optional packages rc-profiler-python/java/rust/node/ruby in the same OS channels as rc-cli. Add-ons declare their own compatible runtimes; SDK installation continues through pip, Maven, npm, etc. Initial CLI plugin commands list and inspect installations; package managers own installation and upgrades. -RC_GO_PROFILER_BIN and --go-profiler retain their standalone binding-tool meaning; they do not -replace the profiler linked into native rc profile commands. +The extensions CLI retains RC_GO_PROFILER_BIN for standalone binding-tool selection. +rc bindings uses its linked profiler by default and accepts explicit --go-profiler selection; +neither setting replaces the profiler linked into native rc profile commands. ## 7. Process behavior and diagnostics @@ -181,7 +208,9 @@ Ship install-layout metadata for both profiles; relocating the tree must preserv Use rc-cli as the package/formula name and rc as the executable; do not rename the accepted command. Do not declare replacement of unrelated distro packages. Package removal deletes only its owned files. Go tooling is a documented analysis prerequisite, not an unconditional dependency of commands like help. -The installed bundle runs without source checkouts and without bootstrap downloads at first invocation. +Installed tooling and shared contracts require no source checkout or bootstrap download. +Extension definitions and normalization's core-schema inputs retain upstream resolution rules; +supply local definitions and --core-schema when those inputs must remain offline. Initial proposed matrix: macOS and Linux amd64/arm64; Debian 12/13 and Ubuntu 24.04/26.04 for APT. Declare tested macOS minimum versions and any libc requirements in platforms.yaml before publishing. @@ -196,7 +225,7 @@ Windows and RPM channels follow after the initial package contract; do not adver GITHUB_SHA, and the verified core profile schema as linker values: ~~~sh - go run ./scripts/build.go --output dist/rc \ + go run ./scripts/build.go --output dist/bin/rc \ --version "${GITHUB_REF_NAME}" --commit "${GITHUB_SHA}" ~~~ @@ -207,6 +236,8 @@ Windows and RPM channels follow after the initial package contract; do not adver Local builds use go run ./scripts/build.go --output ./rc, with optional version/commit flags. Verify rc version, rc --version, and rc version --output yaml report the release tag and source commit. Reject dev/unknown metadata in release artifacts; ordinary local builds retain those defaults. + The helper builds the executable; staging the matching YAML resources remains a separate + build step, as implemented in CI. A complete package includes both bin/rc and share/rc. - Build once from pinned modules and verified resources; inventory the resulting CLI and package without rebuilding. - Produce platform tar.gz archives, SHA256SUMS, component YAML, and .deb packages using pinned nFPM. Map Go amd64/arm64 to the corresponding Debian architecture fields and include package descriptions/licenses. @@ -232,7 +263,7 @@ Windows and RPM channels follow after the initial package contract; do not adver | 1. CLI foundation | Go module, Cobra root/version/completion, injectable dependencies; clean independent build. | | 2. Go default | Pinned linked profiler, native Cobra commands, identical default/explicit Go results and verified core schema. | | 3. Add-on contract | Strict YAML schema, configuration/discovery, compatibility errors, plugin list and doctor. | -| 4. Binding adapter | Versioned library dependencies, typed execution, installed resources and accurate identities; all eight commands preserve behavior. | +| 4. Binding commands | Implemented: pinned libraries, direct typed execution, staged YAML resources, actual identities, and all eight Cobra operations. | | 5. Packaging | Relocatable archives, Homebrew formula, .deb installs/upgrades/removals on the stated matrix. | | 6. Workflow migration | Extensions helpers may use a pinned common CLI after parity; standalone generation remains supported. | | 7. Publication | Protected release, tap/APT promotion, documented installation, checksums and rollback procedure. | @@ -242,6 +273,13 @@ native/optional help, error/exit propagation, cancellation, duplicate IDs, missi explicit override selection, and stdout byte preservation using controlled subprocess fixtures. Compare generated Go profiles and negative-case behavior with the linked profiler API. Compare binding YAML, target defaults, lock failures, cleanup, atomic updates, and output paths with the old CLI. +The CI binding smoke test relocates the complete staged prefix and copies the neutral YAML +fixture into an isolated project. Only Go is available on its subprocess PATH. All eight +binding operations run, with initial release planning using a local read-only GitHub API +fixture. A generated module is installed through a private file proxy and then profiled by +both native Go command routes. Altered-source checks, nonempty generation destinations, +and invalid declarations must fail without replacing existing output. Generated package +trees, archives, profiles, and caches remain temporary build outputs. Install in clean VMs/containers from complete artifacts, away from checkout paths and without a profiler on PATH. Run a Go workload using rc-cli alone plus the documented Go toolchain and resolved workload bindings. Check that no Python/JVM is required; optional-language errors identify the add-on/runtime to install. diff --git a/README.md b/README.md index e98cb67..1d91f31 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # rc-cli -The CLI entrypoint with Golang profiling support, extendable with language-specific profilers +Runtime Conditions commands for extension bindings and workload profiling. ## Build locally @@ -12,9 +12,10 @@ go run ./scripts/build.go --output ./rc ``` The helper downloads the pinned core profile schema, verifies its SHA-256, -and embeds it alongside the linked Go profiler. The resulting binary needs no -schema download or separate profiler installation at runtime. Analyzing a -workload requires a working Go toolchain and its binding dependencies. +and embeds it alongside the linked Go profiler. Go profiling needs no runtime +schema download or separate profiler installation. Analyzing a workload requires +a working Go toolchain and its binding dependencies. CI builds with Go 1.26.5 +and tests workload analysis with Go 1.25.0. A plain `go build` or `go install` omits the schema linker value. Such a binary can display help and version information, but profile generation fails with @@ -30,3 +31,65 @@ go run ./scripts/build.go --output ./rc \ The local schema must match the helper's pinned checksum. Release builds also pass `--version ` and `--commit ` to embed provenance. + +## Binding commands + +```sh +./rc bindings --help +./rc bindings generate --help +``` + +The command group provides `resolve`, `normalize`, `generate`, `verify`, +`package`, `plan-release`, `check`, and `update`. Cobra owns help, completion, +and flag parsing; execution uses the shared binding library from `extensions`. + +Binding commands also require the shared YAML schemas. Installed layouts place +them under `share/rc/extension-bindings/model/`, alongside `bin/rc`. CI stages +these files from the exact `extensions` commit in +`distribution/components.lock.yaml`; they remain ordinary YAML files. + +The local build helper builds the executable. For local binding development, +supply `--tooling-dir` pointing to matching schema resources, such as the +`tooling/extension-bindings` directory in a checkout of that pinned revision: + +```sh +./rc bindings generate \ + --packages /path/to/binding-project/packages.yaml \ + --toolchain-lock /path/to/binding-project/toolchain.lock.yaml \ + --tooling-dir /path/to/extensions/tooling/extension-bindings \ + --core-schema /path/to/runtimeconditions.profile.schema.yaml \ + --output /tmp/generated-bindings +``` + +`packages.yaml` selects extension roots, language targets, and native package +versions. `toolchain.lock.yaml` declares the applicable toolchain constraints. +Use repeatable `--target package-key:language` flags to limit the selected targets. +`generate` requires a new or empty output directory. `verify --input` accepts the +generated tree; use the same catalog and resource flags for these commands. +`update` verifies candidates before changing committed binding +directories; `check` compares regenerated content without updating those files. + +The core schema must match the version declared in the package catalog. Without +`--core-schema`, normalization uses upstream core-schema resolution. Offline +generation also requires locally available extension definitions; native package +verification and profiling require resolved package dependencies and Go. + +The normalizer and Go emitter run as linked libraries. Explicit executable +overrides for those tools conflict with this mode. For binding provenance, +`--go-profiler /absolute/path/to/go-rc-profiler` selects a standalone profiler +file; otherwise the linked profiler's actual module version and checksum are +recorded. The `rc profile` commands always use the linked Go profiler. + +## CI coverage and distribution + +CI tests Linux and macOS on amd64 and arm64. It relocates the staged executable +and YAML resources, exercises all eight binding operations, installs a freshly +generated Go module through a local proxy, and profiles its declarations through +both Go command routes. Negative checks cover altered committed source, nonempty +output directories, and invalid declarations preserving existing profile output. +Only Go is available on the smoke test's subprocess PATH; Python is used by the +CI harness, and is not a runtime dependency of Go binding or profiling commands. + +The small extension fixture is checked in as YAML under `testdata/ci/bindings/`. +Generated packages, archives, profiles, and module caches remain temporary CI +outputs. Homebrew/APT packaging and optional profiler add-ons remain planned. diff --git a/scripts/ci-bindings-smoke.py b/scripts/ci-bindings-smoke.py new file mode 100644 index 0000000..ccb7301 --- /dev/null +++ b/scripts/ci-bindings-smoke.py @@ -0,0 +1,183 @@ +"""Exercise a relocated CLI package, generated bindings, and offline Go profiling.""" + +import argparse +import hashlib +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +from threading import Thread + +import yaml + + +def require(condition, message): + if not condition: + raise RuntimeError(message) + + +def command(args, cwd, env, expected=0): + result = subprocess.run(args, cwd=cwd, env=env, capture_output=True, text=True, timeout=120) + require(result.returncode == expected, f"{args}: exit {result.returncode}, expected {expected}\n{result.stdout}\n{result.stderr}") + return result + + +def tree(directory): + return {path.relative_to(directory).as_posix(): path.read_bytes() for path in directory.rglob("*") if path.is_file()} + + +class NoPublishedReleases(BaseHTTPRequestHandler): + def do_GET(self): + valid = self.path == "/repos/example/ci-bindings/releases?per_page=100&page=1" + self.server.paths.append(self.path) + self.send_response(200 if valid else 404) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(b"[]" if valid else b"{}") + + def log_message(self, *_args): + pass + + +def smoke(args, root): + prefix = root / "install" + shutil.copytree(args.prefix, prefix) + binary = str(prefix / "bin/rc") + lock = yaml.safe_load((prefix / "share/rc-cli/components.lock.yaml").read_text()) + core = args.core_schema.read_bytes() + require(hashlib.sha256(core).hexdigest() == lock["goProfiler"]["coreSchema"]["sha256"], "core schema checksum differs from the component lock") + project = root / "project" + (project / "catalog").mkdir(parents=True) + shutil.copyfile(args.fixture, project / "catalog/runtimeconditions.extension.yaml") + (project / "core.yaml").write_bytes(core) + fixture = yaml.safe_load(args.fixture.read_text()) + tools = root / "tools" + tools.mkdir() + go = shutil.which("go") + require(go, "Go toolchain is unavailable") + (tools / "go").symlink_to(Path(go).resolve()) + env = {key: value for key, value in os.environ.items() if key not in {"GH_TOKEN", "GITHUB_TOKEN", "RC_BINDINGS_GITHUB_API"}} + env.update(PATH=str(tools), GOTOOLCHAIN="local", GOWORK="off", GOFLAGS="-mod=readonly -buildvcs=false", CGO_ENABLED="0", GOPROXY="off", GOSUMDB="off", GOPRIVATE="", GONOPROXY="", GONOSUMDB="", GOMODCACHE=str(root / "modules"), GOPATH=str(root / "gopath")) + env.update(RC_BINDINGS_GO="go", RC_BINDINGS_PYTHON="/must-not-run", RC_GO_PROFILER_BIN="/must-not-run", RC_BINDINGS_NORMALIZER="/must-not-run", RC_BINDINGS_GO_EMITTER="/must-not-run") + native = command(["go", "env", "GOVERSION"], project, env).stdout.strip() + require(native == args.go_version, f"native Go version {native}, expected {args.go_version}") + empty_path = dict(env, PATH=str(root / "no-tools")) + + def rc(*values, expected=0, selected=env, cwd=project): + return command([binary, *values], cwd, selected, expected) + + info = yaml.safe_load(rc("version", "--output", "yaml").stdout) + profiler = next(item for item in info["components"] if item["module"] == "github.com/runtimeconditions/go-rc-profiler") + require(profiler["origin"] == "linked" and profiler["version"] == lock["goProfiler"]["version"], "linked profiler differs from the component lock") + for values in [("--help",), ("bindings", "--help"), ("profile", "go", "--help")]: + require("Usage:" in rc(*values, selected=empty_path).stdout, "help requires unavailable tools") + catalog = { + "apiVersion": "runtimeconditions.io/package-catalog/v1alpha1", "kind": "RuntimeConditionsPackageCatalog", + "repositoryUrl": "https://github.com/example/ci-bindings", "packages": {"fixture": { + "rootExtension": fixture["metadata"]["id"], "coreProfileSchemaVersion": lock["goProfiler"]["coreSchema"]["version"], + "languages": {"go": {"coordinate": "example.test/bindings/fixture", "name": "fixture", "version": fixture["metadata"]["version"], + "sourceDirectory": "bindings/fixture/go", "languageVersion": native.removeprefix("go"), "publicationMode": "github-tag"}}, + }}, + } + (project / "packages.yaml").write_text(yaml.safe_dump(catalog)) + (project / "toolchain.lock.yaml").write_text("apiVersion: runtimeconditions.io/toolchain-lock/v1alpha1\nkind: RuntimeConditionsToolchainLock\nstatus: development\n") + binding_flags = ("--core-schema", str(project / "core.yaml")) + for name in ("resolve", "normalize"): + require("parity.service" in rc("bindings", name, *binding_flags, selected=empty_path).stdout, f"{name} omitted the fixture kind") + require(not (project / "bindings").exists(), "read-only commands created committed bindings") + generated = root / "generated" + rc("bindings", "generate", "--output", str(generated), *binding_flags, selected=empty_path) + summary = yaml.safe_load(rc("bindings", "verify", "--input", str(generated), *binding_flags).stdout) + require(summary["status"] == "passed", "generated tree verification failed") + rc("bindings", "update", *binding_flags) + package = project / "bindings/fixture/go" + original = tree(package) + require(original == tree(generated / "bindings/fixture/go"), "update differs from generate") + rc("bindings", "check", *binding_flags) + archives = root / "archives" + rc("bindings", "package", "--output", str(archives), *binding_flags) + require(tree(package) == original, "check or package changed committed bindings") + with ThreadingHTTPServer(("127.0.0.1", 0), NoPublishedReleases) as server: + server.paths = [] + thread = Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + selected = dict(env, RC_BINDINGS_GITHUB_API=f"http://127.0.0.1:{server.server_address[1]}") + plan = yaml.safe_load(rc("bindings", "plan-release", selected=selected).stdout) + require(plan["releases"][0]["impact"] == "initial" and len(server.paths) == 1, "initial release plan differs from the local fixture") + finally: + server.shutdown() + thread.join(timeout=5) + + release = yaml.safe_load((package / "runtimeconditions.binding-release.yaml").read_text()) + recorded = release["provenance"]["profiler"] + require(recorded["kind"] == "go-module" and recorded["version"] == profiler["version"] and recorded["moduleSum"] == profiler["moduleSum"], "generated profiler identity differs from the executable") + proxy = root / "proxy/example.test/bindings/fixture/@v" + proxy.mkdir(parents=True) + version = release["package"]["version"] + asset = f"fixture-go-{version.removeprefix('v')}" + shutil.copyfile(archives / f"{asset}.module.zip", proxy / f"{version}.zip") + shutil.copyfile(package / "go.mod", proxy / f"{version}.mod") + (proxy / f"{version}.info").write_text(json.dumps({"Version": version, "Time": "2026-01-01T00:00:00Z"})) + (proxy / "list").write_text(version + "\n") + consumer = root / "consumer" + consumer.mkdir() + (consumer / "go.mod").write_text(f"module example.test/consumer\n\ngo {native.removeprefix('go')}\n\nrequire example.test/bindings/fixture {version}\n") + source = 'package main\nimport "example.test/bindings/fixture"\nvar _ = fixture.ParityService(fixture.Stream{Address: "example.test:1234"})\nfunc init(){panic("the workload must not execute")}\nfunc main(){}\n' + (consumer / "main.go").write_text(source) + command(["go", "mod", "download", "all"], consumer, dict(env, GOPROXY=(root / "proxy").as_uri())) + rc("profile", "go", "validate-extension", "--dir", str(consumer), "--package", "example.test/bindings/fixture") + flags = ("--dir", str(consumer), "--name", "ci-bindings", "--workload-uri", "https://example.test/ci", "--workload-version", "1.0.0") + default = rc("profile", "generate", *flags) + explicit = rc("profile", "go", "generate", *flags) + require(default.stdout == explicit.stdout, "profile routes produced different bytes") + profile = yaml.safe_load(default.stdout) + require(profile["extensions"] == [fixture["metadata"]["id"]] and profile["conditions"][0]["interface"] == {"type": "stream", "address": "example.test:1234"}, "generated profile differs from the fixture") + + output = root / "profile.yaml" + output.write_text("preserve existing profile\n") + (consumer / "main.go").write_text(source.replace('"example.test:1234"', '""')) + for route in (("profile",), ("profile", "go")): + failed = rc(*route, "generate", *flags, "--out", str(output), expected=1) + require(not failed.stdout and output.read_text() == "preserve existing profile\n", "invalid declaration replaced profile output") + changed = next(package.glob("*.go")) + changed.write_bytes(original[changed.name] + b"\n// altered by the smoke test\n") + rc("bindings", "check", *binding_flags, expected=1) + require(changed.read_bytes() == original[changed.name] + b"\n// altered by the smoke test\n", "check rewrote committed source") + changed.write_bytes(original[changed.name]) + occupied = root / "occupied" + occupied.mkdir() + (occupied / "keep").write_bytes(b"keep") + rc("bindings", "generate", "--output", str(occupied), *binding_flags, expected=1) + require(tree(occupied) == {"keep": b"keep"}, "generate overwrote a nonempty destination") + require(tree(package) == original, "smoke checks changed the final committed tree") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--prefix", type=Path, required=True) + parser.add_argument("--fixture", type=Path, required=True) + parser.add_argument("--core-schema", type=Path, required=True) + parser.add_argument("--go-version", required=True) + args = parser.parse_args() + args.prefix, args.fixture, args.core_schema = args.prefix.resolve(), args.fixture.resolve(), args.core_schema.resolve() + with tempfile.TemporaryDirectory(prefix="rc-cli-bindings-smoke-") as temporary: + root = Path(temporary) + try: + smoke(args, root) + finally: + for directory, _dirs, _files in os.walk(root / "modules"): + os.chmod(directory, 0o755) + print("PASS: relocated CLI, all eight binding operations, generated-module profiling, and output protection") + + +if __name__ == "__main__": + try: + main() + except (OSError, RuntimeError, ValueError, subprocess.TimeoutExpired, yaml.YAMLError) as error: + print(f"ci-bindings-smoke: {error}", file=sys.stderr) + sys.exit(1) diff --git a/testdata/ci/bindings/runtimeconditions.extension.yaml b/testdata/ci/bindings/runtimeconditions.extension.yaml new file mode 100644 index 0000000..cbd3847 --- /dev/null +++ b/testdata/ci/bindings/runtimeconditions.extension.yaml @@ -0,0 +1,35 @@ +apiVersion: runtimeconditions.io/v1alpha1 +kind: RuntimeConditionsExtensionDefinition +metadata: + id: https://bindings.example.test/parity:0.1.0 + version: 0.1.0 +spec: + kinds: + - name: parity.service + interfaceTypes: + - name: stream + targetKind: parity.service + interfaceFields: + - name: address + targetKind: parity.service + targetType: stream + schemas: + - id: stream-address + description: Stream address for the installed CLI binding and profiling test. + appliesToKind: parity.service + appliesToInterfaceType: stream + schema: + type: object + required: [kind, interface] + properties: + kind: + const: parity.service + interface: + type: object + required: [type, address] + properties: + type: + const: stream + address: + type: string + minLength: 1 From 53ba9b1bb88a5e38c98010720669886a31e1c9e5 Mon Sep 17 00:00:00 2001 From: Colin Lacy Date: Wed, 11 Nov 2026 09:02:23 +0000 Subject: [PATCH 7/7] adds go profiler commands to readme --- README.md | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/README.md b/README.md index 1d91f31..008ce5a 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,58 @@ go run ./scripts/build.go --output ./rc \ The local schema must match the helper's pinned checksum. Release builds also pass `--version ` and `--commit ` to embed provenance. +## Built-in Go profiler commands + +`rc profile` uses the built-in Go profiler by default. The same commands are +available under `rc profile go`, with identical flags and behavior: + +| Command | Purpose | +| --- | --- | +| `rc profile generate` | Generate a validated profile from Go binding declarations. | +| `rc profile validate-extensions` | Validate all imported generated binding packages. | +| `rc profile validate-extension` | Validate one imported binding package selected with `--package`. | +| `rc profile version` | Show the linked Go profiler version. | + +For command help: + +```sh +./rc profile --help +./rc profile go --help +./rc profile go generate --help +``` + +Generate a profile using the default or explicit Go route: + +```sh +./rc profile generate --dir /path/to/go/workload --out profile.yaml + +./rc profile go generate \ + --dir /path/to/go/workload \ + --name my-workload \ + --workload-uri https://example.com/my-workload \ + --workload-version 1.0.0 \ + --out profile.yaml +``` + +`--dir` defaults to the current directory. Generation writes YAML to stdout +when `--out` is omitted. The default profile name is the workload directory's +basename; the default workload URI is its enclosing Go module path, falling +back to the directory path. The workload version defaults to `dev`. + +Validate imported bindings or inspect the profiler version: + +```sh +./rc profile go validate-extensions --dir /path/to/go/workload +./rc profile go validate-extension \ + --dir /path/to/go/workload \ + --package runtimeconditions.io/x/rc/common +./rc profile go version +``` + +Profiling analyzes declarations without executing the workload. Resolve the +workload's Go binding dependencies before running these commands. A failed +generation leaves an existing `--out` file unchanged. + ## Binding commands ```sh