diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d84a2db..651f6b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,6 +55,82 @@ 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 urllib.request + 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) + 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: EXPECTED_GOOS: ${{ matrix.goos }} @@ -79,7 +155,8 @@ 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" \ + --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 @@ -93,9 +170,47 @@ 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 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' + 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/CLI-IMPLEMENTATION.md b/CLI-IMPLEMENTATION.md index 2c66e01..72b17fd 100644 --- a/CLI-IMPLEMENTATION.md +++ b/CLI-IMPLEMENTATION.md @@ -1,6 +1,8 @@ # CLI implementation plan -Status: proposed implementation sequence, 2026-11-07. +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). @@ -12,7 +14,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 +22,18 @@ 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. +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 # translates explicit Cobra options to backend argv +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 @@ -52,39 +56,44 @@ 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. +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. 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 +105,54 @@ 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. +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. +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 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 -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 +166,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 +180,15 @@ 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. +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 -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 +198,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. @@ -177,32 +208,40 @@ 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. -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 - 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/bin/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. + 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. -- 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. @@ -222,23 +261,30 @@ 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 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 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. +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. 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 diff --git a/README.md b/README.md index 3350681..008ce5a 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,147 @@ # 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 + +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. 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 +`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. + +## 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 +./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/distribution/components.lock.yaml b/distribution/components.lock.yaml index 348b640..a5de6c9 100644 --- a/distribution/components.lock.yaml +++ b/distribution/components.lock.yaml @@ -1,7 +1,10 @@ apiVersion: runtimeconditions.io/cli-components/v1alpha1 +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 @@ -10,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 6548fe1..76fbba8 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,10 @@ go 1.25.0 toolchain go1.26.5 require ( - github.com/runtimeconditions/go-rc-profiler v0.6.0 + 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.1 github.com/spf13/cobra v1.10.2 go.yaml.in/yaml/v3 v3.0.4 golang.org/x/mod v0.28.0 @@ -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..9097594 100644 --- a/go.sum +++ b/go.sum @@ -5,8 +5,14 @@ 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/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/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.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/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/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"` 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