diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d84a2db --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,101 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + BUILD_GO_VERSION: '1.26.5' + ANALYSIS_GO_VERSION: '1.25.0' + GOTOOLCHAIN: local + GOWORK: 'off' + GOFLAGS: -mod=readonly + +jobs: + check: + name: ${{ matrix.goos }}/${{ matrix.goarch }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-24.04 + goos: linux + goarch: amd64 + - runner: ubuntu-24.04-arm + goos: linux + goarch: arm64 + - runner: macos-15-intel + goos: darwin + goarch: amd64 + - runner: macos-15 + goos: darwin + goarch: arm64 + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + persist-credentials: false + - name: Set up the pinned build toolchain + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version: ${{ env.BUILD_GO_VERSION }} + cache-dependency-path: | + go.sum + testdata/ci/go-project/go.sum + - name: Verify toolchain, platform, and formatting + env: + EXPECTED_GOOS: ${{ matrix.goos }} + EXPECTED_GOARCH: ${{ matrix.goarch }} + run: | + test "$(go env GOVERSION)" = "go$BUILD_GO_VERSION" + test "$(go env GOHOSTOS)" = "$EXPECTED_GOOS" + test "$(go env GOHOSTARCH)" = "$EXPECTED_GOARCH" + unformatted=$(gofmt -l .) + if [[ -n "$unformatted" ]]; then + printf '%s\n' "$unformatted" + exit 1 + fi + - name: Run race tests and static checks + run: | + go test -race ./... + go vet ./... + go mod verify + - name: Build the CLI and smoke harness + env: + CI_VERSION: ci + CI_COMMIT: ${{ github.sha }} + run: | + CGO_ENABLED=0 go run ./scripts/build.go \ + --output "$RUNNER_TEMP/rc-ci/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 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version: ${{ env.ANALYSIS_GO_VERSION }} + cache: false + - name: Verify the isolated installed executable + env: + CI_COMMIT: ${{ github.sha }} + run: | + test "$(go env GOVERSION)" = "go$ANALYSIS_GO_VERSION" + "$RUNNER_TEMP/rc-ci-smoke" \ + --rc "$RUNNER_TEMP/rc-ci/rc" \ + --fixture "$GITHUB_WORKSPACE/testdata/ci/go-project" \ + --version ci --commit "$CI_COMMIT" \ + --go-version "go$ANALYSIS_GO_VERSION" + - name: Verify tracked files were not rewritten + run: git diff --exit-code -- . diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ec84d2d --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +AGENTS.md +.DS_Store +.idea \ No newline at end of file diff --git a/CLI-IMPLEMENTATION.md b/CLI-IMPLEMENTATION.md new file mode 100644 index 0000000..2c66e01 --- /dev/null +++ b/CLI-IMPLEMENTATION.md @@ -0,0 +1,248 @@ +# CLI implementation plan + +Status: proposed implementation sequence, 2026-11-07. +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). +Binding behavior follows [IMPLEMENTATION.md, sections 12–13](../extensions/tooling/extension-bindings/IMPLEMENTATION.md). +These sibling links assume the repositories share a parent directory; published documentation must link to pinned GitHub revisions. + +## 1. Scope and ownership + +- 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. +- 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. +- This document authorizes no external publication or edits to another repository. + +## 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. + +~~~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/plugins/{descriptor,discover,compatibility}.go +internal/process/runner.go # invocation, exit status, cancellation +internal/install/layout.go # archive, Homebrew, and Linux installation roots +schemas/plugin.schema.yaml # add-on metadata contract +distribution/{components.lock.yaml,nfpm.yaml,platforms.yaml} +distribution/homebrew/rc-cli.rb # template for the organization's tap +scripts/ # build, stage, package, verify, publish helpers +.github/workflows/{checks,release}.yml +go.mod / go.sum +~~~ + +Pin Cobra, the CLI build Go version, and packaging tools; commit dependency checksums. +Keep command construction injectable through streams, discovery, and runner interfaces. +Follow AGENTS.md: implement complete units in batches of at most five named files and 250 changed lines per file. + +## 3. Command and argument contract + +~~~text +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 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 --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`; + 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. + 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. +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. + +| Operation | Required preservation | +| --- | --- | +| resolve / normalize | YAML stdout; write only explicitly requested closure, model, and lock files. | +| generate | Required new/empty output directory; generate selected target trees. | +| verify | Inspect supplied or committed trees; optional explicit report. | +| package | Compare regeneration with committed trees, verify and archive committed source. | +| plan-release | Report compatibility and required versions without publishing. | +| 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. +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. + +## 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. + +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. +Do not infer target-toolchain compatibility solely from the profiler's current go.mod build version. +Missing Go must not prevent help, plugin listing, version output, or other languages from working. +Report the missing prerequisite without downloading Go or installing workload modules automatically. +Go-only users install neither Python nor a JVM. Native SDK resolution remains the profiler's responsibility. + +## 6. Add-on protocol, configuration, and lookup + +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. +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 +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. +Relative managed executable paths stay within their package root; user overrides are explicit absolute paths. +User configuration describes the profiler environment, including an interpreter and argv prefix when needed. +Python inspection must use the workload's intended binding environment; an isolated system CLI environment +cannot assume access to its distributions. Resolve that backend requirement before shipping its add-on. +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. + +## 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. +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 +failing unrelated commands. Listing metadata does not require runtimes or network access. +On Unix, forward interrupt/termination to the child process group, allow bounded cleanup, then terminate +remaining descendants. Preserve conventional signal exit results and avoid leaking compiler subprocesses. + +## 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. +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. +For archives, require extraction of the complete tree and document adding its bin directory to PATH. +Ship install-layout metadata for both profiles; relocating the tree must preserve managed tool lookup. +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. + +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. +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: + + ~~~sh + mkdir -p dist + go build -trimpath \ + -ldflags "-X main.version=${GITHUB_REF_NAME} -X main.commit=${GITHUB_SHA}" \ + -o dist/rc ./cmd/rc + ~~~ + + 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. +- 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. +- 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. + A default brew install runtimeconditions/tools/rc-cli installs the complete bundle and completions. +- Publish Debian packages through one signed APT host with separate tested suite indexes where needed. + Generate Packages/Release/InRelease from immutable packages with a pinned repository tool such as reprepro. +- Document the public key fingerprint and bootstrap; use a dedicated keyring and Signed-By in a .sources entry. + APT users configure the repository once, then apt install/upgrade rc-cli and optional language packages. +- Put signing credentials in the protected publication environment; serialize repository updates and publish + metadata after referenced packages. Retain prior artifacts and document key rotation and rollback policy. +- A hosting repository/site and release credentials are deployment inputs; create no organization requirement. + GitHub Pages/static HTTPS hosting or a managed APT service can host the same repository layout. +- Keep prereleases out of the stable tap/APT channel. Retries verify existing asset hashes and are idempotent. + +## 10. Acceptance and ordered work packages + +| 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. | +| 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. | +| 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. | +| 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, +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 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 +acceptance, and both distribution channels install the same reviewed component versions. + +## References + +- [Cobra command/flag behavior](https://github.com/spf13/cobra/blob/main/site/content/user_guide.md) and [DisableFlagParsing](https://github.com/spf13/cobra/blob/main/command.go). +- [Homebrew formula packaging](https://docs.brew.sh/Formula-Cookbook) and [nFPM configuration](https://nfpm.goreleaser.com/docs/configuration/). +- [APT authentication](https://manpages.debian.org/testing/apt/apt-secure.8.en.html) and [repository key scoping](https://manpages.debian.org/testing/apt/sources.list.5.en.html). diff --git a/cmd/rc/main.go b/cmd/rc/main.go new file mode 100644 index 0000000..66f1f25 --- /dev/null +++ b/cmd/rc/main.go @@ -0,0 +1,29 @@ +package main + +import ( + "context" + "os" + "os/signal" + "syscall" + + "github.com/runtimeconditions/rc-cli/internal/cli" +) + +// Release builds set these with -ldflags -X main.version=... -X main.commit=.... +var ( + version = "dev" + commit = "unknown" +) + +func main() { + os.Exit(run()) +} + +func run() int { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + return cli.Execute(ctx, os.Args[1:], cli.BuildInfo{ + Version: version, + Commit: commit, + }, cli.IOStreams{In: os.Stdin, Out: os.Stdout, ErrOut: os.Stderr}) +} diff --git a/distribution/components.lock.yaml b/distribution/components.lock.yaml new file mode 100644 index 0000000..348b640 --- /dev/null +++ b/distribution/components.lock.yaml @@ -0,0 +1,27 @@ +apiVersion: runtimeconditions.io/cli-components/v1alpha1 +goProfiler: + version: v0.6.0 + commit: 4dc71655a74839fdee5fc5226071e1cda39c2234 + protocol: go-profiler-argv/v1 + coreSchema: + version: 0.4.0 + id: https://runtimeconditions.io/schemas/profile/0.4.0/runtimeconditions.profile.schema.yaml + url: https://github.com/runtimeconditions/spec/releases/download/v0.4.0/runtimeconditions.profile.schema.yaml + 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 + 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 + 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 + 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 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6548fe1 --- /dev/null +++ b/go.mod @@ -0,0 +1,22 @@ +module github.com/runtimeconditions/rc-cli + +go 1.25.0 + +toolchain go1.26.5 + +require ( + github.com/runtimeconditions/go-rc-profiler v0.6.0 + github.com/spf13/cobra v1.10.2 + go.yaml.in/yaml/v3 v3.0.4 + golang.org/x/mod v0.28.0 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect + github.com/spf13/pflag v1.0.9 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/text v0.14.0 // indirect + golang.org/x/tools v0.37.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..47c964f --- /dev/null +++ b/go.sum @@ -0,0 +1,30 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +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/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= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U= +golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= +golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/cli/go_profiler.go b/internal/cli/go_profiler.go new file mode 100644 index 0000000..341ceff --- /dev/null +++ b/internal/cli/go_profiler.go @@ -0,0 +1,191 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "runtime/debug" + + "github.com/runtimeconditions/go-rc-profiler/extensioncheck" + "github.com/runtimeconditions/go-rc-profiler/extractor" + "github.com/spf13/cobra" + "golang.org/x/mod/modfile" +) + +const goProfilerModule = "github.com/runtimeconditions/go-rc-profiler" + +func linkedGoProfilerIdentity() goProfilerIdentity { + identity := goProfilerIdentity{Name: "go-rc-profiler", Origin: "linked", Module: goProfilerModule, Version: "unknown"} + if build, ok := debug.ReadBuildInfo(); ok { + for _, dependency := range build.Deps { + if dependency.Path == goProfilerModule { + if dependency.Replace != nil { + dependency = dependency.Replace + } + identity.Version, identity.ModuleSum = dependency.Version, dependency.Sum + break + } + } + } + return identity +} + +type goProfilerAPI struct { + Extract func(string, extractor.GeneratedOptions) (map[string]any, error) + Marshal func(map[string]any) ([]byte, error) + Validate func(context.Context, string, []string) ([]extensioncheck.ImportedGoPackage, error) +} + +func linkedGoProfilerAPI() goProfilerAPI { + return goProfilerAPI{ + Extract: extractor.ExtractGeneratedDir, Marshal: extractor.MarshalProfileYAML, + Validate: extensioncheck.ValidateImportedGoPackages, + } +} + +type goGenerateOptions struct { + Directory string + Output string + Identity extractor.GeneratedOptions +} + +func newGoGenerateCommand(api goProfilerAPI) *cobra.Command { + var options goGenerateOptions + cmd := &cobra.Command{ + Use: "generate", Short: "Generate a validated profile from Go declarations", + Args: usageArgs(cobra.NoArgs), ValidArgsFunction: cobra.NoFileCompletions, + RunE: func(cmd *cobra.Command, _ []string) error { + return generateGoProfile(cmd.Context(), options, api, cmd.OutOrStdout()) + }, + } + flags := cmd.Flags() + flags.StringVar(&options.Directory, "dir", ".", "directory containing Go source declarations") + flags.StringVar(&options.Identity.Name, "name", "", "profile metadata.name") + flags.StringVar(&options.Identity.WorkloadURI, "workload-uri", "", "workload.uri") + flags.StringVar(&options.Identity.WorkloadVersion, "workload-version", "dev", "workload.version") + flags.StringVar(&options.Output, "out", "", "output file path; defaults to stdout") + return cmd +} + +func newGoValidateCommand(api goProfilerAPI, single bool) *cobra.Command { + var directory, packagePath string + cmd := &cobra.Command{ + Use: "validate-extensions", Short: "Validate imported Go binding packages", + Args: usageArgs(cobra.NoArgs), ValidArgsFunction: cobra.NoFileCompletions, + RunE: func(cmd *cobra.Command, _ []string) error { + if err := cmd.Context().Err(); err != nil { + return err + } + absolute, err := filepath.Abs(directory) + if err != nil { + return err + } + var packages []string + if single { + packages = []string{packagePath} + } + if _, err := api.Validate(cmd.Context(), absolute, packages); err != nil { + return err + } + _, err = fmt.Fprintln(cmd.ErrOrStderr(), "runtimeconditions: generated binding structures validated") + return err + }, + } + cmd.Flags().StringVar(&directory, "dir", ".", "Go workload module directory") + if single { + cmd.Use, cmd.Short = "validate-extension", "Validate an imported Go binding package" + cmd.Flags().StringVar(&packagePath, "package", "", "import path of a generated extension binding package") + _ = cmd.MarkFlagRequired("package") + cmd.PreRunE = func(_ *cobra.Command, _ []string) error { + if packagePath == "" { + return usageError{errors.New("--package is required")} + } + return nil + } + } + return cmd +} + +func newGoVersionCommand() *cobra.Command { + return &cobra.Command{ + Use: "version", Short: "Show the linked Go profiler version", + Args: usageArgs(cobra.NoArgs), ValidArgsFunction: cobra.NoFileCompletions, + RunE: func(cmd *cobra.Command, _ []string) error { + identity := linkedGoProfilerIdentity() + _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s %s (linked into rc)\n", identity.Name, identity.Version) + return err + }, + } +} + +func generateGoProfile(ctx context.Context, options goGenerateOptions, api goProfilerAPI, out io.Writer) error { + if err := ctx.Err(); err != nil { + return err + } + directory, err := filepath.Abs(options.Directory) + if err != nil { + return err + } + if options.Identity.Name == "" { + options.Identity.Name = filepath.Base(directory) + } + if options.Identity.WorkloadURI == "" { + options.Identity.WorkloadURI = goWorkloadURI(directory) + } + // The upstream extraction API has no context parameter. Check cancellation + // around the call without leaving analysis running in a goroutine. + profile, err := api.Extract(directory, options.Identity) + if err != nil { + return err + } + if err := ctx.Err(); err != nil { + return err + } + data, err := api.Marshal(profile) + if err != nil { + return err + } + if options.Output != "" { + return writeGoProfileAtomically(options.Output, data) + } + _, err = out.Write(data) + return err +} + +func goWorkloadURI(directory string) string { + for current := directory; ; current = filepath.Dir(current) { + if data, err := os.ReadFile(filepath.Join(current, "go.mod")); err == nil { + if module := modfile.ModulePath(data); module != "" { + return module + } + } + if parent := filepath.Dir(current); parent == current { + return directory + } + } +} + +func writeGoProfileAtomically(path string, data []byte) error { + file, err := os.CreateTemp(filepath.Dir(path), ".runtimeconditions-profile-*") + if err != nil { + return err + } + defer os.Remove(file.Name()) + defer file.Close() + if err := file.Chmod(0644); err != nil { + return err + } + if _, err := file.Write(data); err != nil { + return err + } + if err := file.Sync(); err != nil { + return err + } + if err := file.Close(); err != nil { + return err + } + return os.Rename(file.Name(), path) +} diff --git a/internal/cli/go_profiler_test.go b/internal/cli/go_profiler_test.go new file mode 100644 index 0000000..1af3bd7 --- /dev/null +++ b/internal/cli/go_profiler_test.go @@ -0,0 +1,220 @@ +package cli + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/runtimeconditions/go-rc-profiler/extensioncheck" + "github.com/runtimeconditions/go-rc-profiler/extractor" + "github.com/spf13/cobra" +) + +func testGoAPI() goProfilerAPI { + return goProfilerAPI{ + Extract: func(string, extractor.GeneratedOptions) (map[string]any, error) { + return map[string]any{"kind": "RuntimeConditionsProfile"}, nil + }, + Marshal: func(map[string]any) ([]byte, error) { return []byte("kind: RuntimeConditionsProfile\n"), nil }, + Validate: func(context.Context, string, []string) ([]extensioncheck.ImportedGoPackage, error) { + return nil, nil + }, + } +} + +func testGoRoot(api goProfilerAPI) (*cobra.Command, *bytes.Buffer, *bytes.Buffer) { + var out, errOut bytes.Buffer + root := NewRootCommand(BuildInfo{}, IOStreams{Out: &out, ErrOut: &errOut}) + current, _, _ := root.Find([]string{"profile"}) + root.RemoveCommand(current) + root.AddCommand(newGoProfileCommands(api)) + return root, &out, &errOut +} + +func TestGoGenerateTypedOptions(t *testing.T) { + project := t.TempDir() + for _, prefix := range [][]string{{"profile"}, {"profile", "go"}} { + api := testGoAPI() + var directory string + var options extractor.GeneratedOptions + api.Extract = func(dir string, opts extractor.GeneratedOptions) (map[string]any, error) { + directory, options = dir, opts + return map[string]any{"kind": "RuntimeConditionsProfile"}, nil + } + root, out, errOut := testGoRoot(api) + args := append(append([]string{}, prefix...), "generate", "--dir", project, + "--name", "first", "--name", "service with spaces", "--workload-uri", "https://example.com/service", "--workload-version", "1.2.3") + root.SetArgs(args) + if err := root.ExecuteContext(context.Background()); err != nil { + t.Fatal(err) + } + want := extractor.GeneratedOptions{Name: "service with spaces", WorkloadURI: "https://example.com/service", WorkloadVersion: "1.2.3"} + if directory != project || options != want || out.String() != "kind: RuntimeConditionsProfile\n" || errOut.Len() != 0 { + t.Fatalf("%v: directory=%q options=%+v stdout=%q stderr=%q", prefix, directory, options, out.String(), errOut.String()) + } + } +} + +func TestGoGenerateDefaultsAndFileOutput(t *testing.T) { + project := t.TempDir() + if err := os.WriteFile(filepath.Join(project, "go.mod"), []byte("module example.com/service\n"), 0644); err != nil { + t.Fatal(err) + } + api := testGoAPI() + api.Extract = func(dir string, options extractor.GeneratedOptions) (map[string]any, error) { + if dir != project || options.Name != filepath.Base(project) || options.WorkloadURI != "example.com/service" || options.WorkloadVersion != "dev" { + t.Fatalf("directory=%q options=%+v", dir, options) + } + return map[string]any{}, nil + } + path := filepath.Join(project, "profile.yaml") + root, out, errOut := testGoRoot(api) + root.SetArgs([]string{"profile", "generate", "--dir", project, "--out", path}) + if err := root.ExecuteContext(context.Background()); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(path) + if err != nil || string(data) != "kind: RuntimeConditionsProfile\n" || out.Len() != 0 || errOut.Len() != 0 { + t.Fatalf("data=%q err=%v stdout=%q stderr=%q", data, err, out.String(), errOut.String()) + } + if temporary, _ := filepath.Glob(filepath.Join(project, ".runtimeconditions-profile-*")); len(temporary) != 0 { + t.Fatalf("temporary output remains: %v", temporary) + } +} + +func TestGoValidationLibraryCalls(t *testing.T) { + project := t.TempDir() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + for _, prefix := range [][]string{{"profile"}, {"profile", "go"}} { + for _, single := range []bool{false, true} { + api := testGoAPI() + called := false + api.Validate = func(actual context.Context, dir string, packages []string) ([]extensioncheck.ImportedGoPackage, error) { + called = true + var want []string + if single { + want = []string{"example.com/binding"} + } + if actual != ctx || dir != project || !reflect.DeepEqual(packages, want) { + t.Fatalf("context propagated=%t directory=%q packages=%v", actual == ctx, dir, packages) + } + return nil, nil + } + args := append(append([]string{}, prefix...), "validate-extensions", "--dir", project) + if single { + args[len(prefix)] = "validate-extension" + args = append(args, "--package", "example.com/binding") + } + root, out, errOut := testGoRoot(api) + root.SetArgs(args) + if err := root.ExecuteContext(ctx); err != nil || !called || out.Len() != 0 || + errOut.String() != "runtimeconditions: generated binding structures validated\n" { + t.Fatalf("%v: called=%t err=%v stdout=%q stderr=%q", args, called, err, out.String(), errOut.String()) + } + } + } +} + +func TestGoUsageAndHelp(t *testing.T) { + for _, args := range [][]string{ + {"profile", "generate", "--unknown"}, {"profile", "generate", "-dir", "."}, + {"profile", "go", "generate", "extra"}, {"profile", "java", "generate"}, + {"profile", "validate-extension"}, {"profile", "go", "validate-extension", "--package", ""}, + } { + code, out, errOut := invoke(t, args...) + if code != 2 || out != "" || strings.Count(errOut, "rc: ") != 1 || !strings.Contains(errOut, "Usage:") { + t.Fatalf("%v: code=%d stdout=%q stderr=%q", args, code, out, errOut) + } + } + t.Setenv("PATH", "") + t.Setenv("RC_GO_PROFILER_BIN", "/does/not/exist") + for _, args := range [][]string{{"profile"}, {"profile", "go"}, {"help", "profile", "go", "generate"}, {"profile", "generate", "--help"}} { + code, out, errOut := invoke(t, args...) + if code != 0 || !strings.Contains(out, "Usage:") || errOut != "" { + t.Fatalf("%v: code=%d stdout=%q stderr=%q", args, code, out, errOut) + } + } + code, out, errOut := invoke(t, "profile", "go", "generate", "--help") + if code != 0 || !strings.Contains(out, "--dir") || !strings.Contains(out, "--out") || errOut != "" { + t.Fatalf("code=%d stdout=%q stderr=%q", code, out, errOut) + } +} + +func TestGoAliasFlagIsolationAndCompletion(t *testing.T) { + root, _, _ := testGoRoot(testGoAPI()) + defaultCommand, _, _ := root.Find([]string{"profile", "generate"}) + explicitCommand, _, _ := root.Find([]string{"profile", "go", "generate"}) + if defaultCommand == explicitCommand { + t.Fatal("aliases share a command instance") + } + if err := defaultCommand.Flags().Set("name", "default route"); err != nil { + t.Fatal(err) + } + if name, _ := explicitCommand.Flags().GetString("name"); name != "" { + t.Fatal("alias flags share state") + } + code, out, _ := invoke(t, "__complete", "profile", "go", "generate", "--na") + if code != 0 || !strings.Contains(out, "--name") { + t.Fatalf("code=%d completion=%q", code, out) + } +} + +func TestGoLibraryFailuresPreserveOutput(t *testing.T) { + for _, stage := range []string{"extract", "marshal", "validate"} { + want := errors.New("library failure") + api := testGoAPI() + switch stage { + case "extract": + api.Extract = func(string, extractor.GeneratedOptions) (map[string]any, error) { return nil, want } + case "marshal": + api.Marshal = func(map[string]any) ([]byte, error) { return nil, want } + case "validate": + api.Validate = func(context.Context, string, []string) ([]extensioncheck.ImportedGoPackage, error) { return nil, want } + } + directory := t.TempDir() + path := filepath.Join(directory, "profile.yaml") + if err := os.WriteFile(path, []byte("preserve"), 0644); err != nil { + t.Fatal(err) + } + root, out, errOut := testGoRoot(api) + args := []string{"profile", "generate", "--dir", directory, "--out", path} + if stage == "validate" { + args = []string{"profile", "validate-extensions", "--dir", directory} + } + root.SetArgs(args) + if err := root.ExecuteContext(context.Background()); !errors.Is(err, want) { + t.Fatalf("%s: %v", stage, err) + } + data, _ := os.ReadFile(path) + if string(data) != "preserve" || out.Len() != 0 || errOut.Len() != 0 { + t.Fatalf("%s: output changed", stage) + } + } +} + +func TestGoCancellationAndOutputFailure(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + api := testGoAPI() + api.Extract = func(string, extractor.GeneratedOptions) (map[string]any, error) { + cancel() + return map[string]any{}, nil + } + root, out, _ := testGoRoot(api) + root.SetArgs([]string{"profile", "generate"}) + if err := root.ExecuteContext(ctx); !errors.Is(err, context.Canceled) || out.Len() != 0 { + t.Fatalf("err=%v stdout=%q", err, out.String()) + } + root, _, _ = testGoRoot(testGoAPI()) + root.SetOut(failingWriter{}) + root.SetArgs([]string{"profile", "generate"}) + if err := root.ExecuteContext(context.Background()); err == nil || !strings.Contains(err.Error(), "output closed") { + t.Fatalf("output failure: %v", err) + } +} diff --git a/internal/cli/profile.go b/internal/cli/profile.go new file mode 100644 index 0000000..1b3c86f --- /dev/null +++ b/internal/cli/profile.go @@ -0,0 +1,42 @@ +package cli + +import "github.com/spf13/cobra" + +type goProfilerIdentity struct { + Name string `yaml:"name"` + Path string `yaml:"path,omitempty"` + Origin string `yaml:"origin"` + Version string `yaml:"version,omitempty"` + Commit string `yaml:"commit,omitempty"` + SHA256 string `yaml:"sha256,omitempty"` + Module string `yaml:"module,omitempty"` + ModuleSum string `yaml:"moduleSum,omitempty"` +} + +func newProfileCommand() *cobra.Command { + return newGoProfileCommands(linkedGoProfilerAPI()) +} + +func newGoProfileCommands(api goProfilerAPI) *cobra.Command { + profile := &cobra.Command{ + Use: "profile", Short: "Generate profiles with the built-in Go profiler", + Long: "Generate profiles with the Go profiler linked into rc. Go analysis requires a working Go toolchain.", + Example: " rc profile generate --dir ./service\n rc profile go generate --dir ./service", + } + goCommand := &cobra.Command{Use: "go", Short: "Inspect Go bindings and generate profiles"} + for _, parent := range []*cobra.Command{profile, goCommand} { + parent.Args = usageArgs(cobra.NoArgs) + parent.ValidArgsFunction = cobra.NoFileCompletions + parent.RunE = func(cmd *cobra.Command, _ []string) error { return cmd.Help() } + parent.Version = linkedGoProfilerIdentity().Version + parent.SetVersionTemplate("{{printf \"go-rc-profiler %s (linked into rc)\\n\" .Version}}") + parent.AddCommand(newGoGenerateCommand(api), newGoValidateCommand(api, true), + newGoValidateCommand(api, false), newGoVersionCommand()) + } + profile.AddCommand(goCommand) + return profile +} + +func goProfilerComponents() ([]goProfilerIdentity, error) { + return []goProfilerIdentity{linkedGoProfilerIdentity()}, nil +} diff --git a/internal/cli/root.go b/internal/cli/root.go new file mode 100644 index 0000000..7fc2570 --- /dev/null +++ b/internal/cli/root.go @@ -0,0 +1,185 @@ +// Package cli constructs the Runtime Conditions command tree without process-global state. +package cli + +import ( + "context" + "errors" + "fmt" + "io" + "runtime" + "strings" + + "github.com/spf13/cobra" + "go.yaml.in/yaml/v3" +) + +// BuildInfo identifies the CLI build independently of its installed components. +type BuildInfo struct { + Version string `yaml:"version"` + Commit string `yaml:"commit"` +} + +// IOStreams gives each invocation its own input and output destinations. +type IOStreams struct { + In io.Reader + Out io.Writer + ErrOut io.Writer +} + +func (s IOStreams) normalized() IOStreams { + if s.In == nil { + s.In = strings.NewReader("") + } + if s.Out == nil { + s.Out = io.Discard + } + if s.ErrOut == nil { + s.ErrOut = io.Discard + } + return s +} + +type usageError struct{ error } + +// Execute reports errors once and returns the process exit code without calling os.Exit. +func Execute(ctx context.Context, args []string, build BuildInfo, streams IOStreams) int { + streams = streams.normalized() + root := NewRootCommand(build, streams) + // A non-nil empty slice prevents Cobra from falling back to os.Args. + root.SetArgs(append([]string{}, args...)) + err := ctx.Err() + var command *cobra.Command + if err == nil { + command, err = root.ExecuteContextC(ctx) + } + if err == nil { + return 0 + } + fmt.Fprintf(streams.ErrOut, "rc: %v\n", err) + if errors.Is(err, context.Canceled) { + return 130 + } + var usage usageError + if errors.As(err, &usage) { + if command != nil { + fmt.Fprint(streams.ErrOut, command.UsageString()) + } + return 2 + } + return 1 +} + +// NewRootCommand returns a fresh command tree. Callers may attach feature commands before execution. +func NewRootCommand(build BuildInfo, streams IOStreams) *cobra.Command { + streams = streams.normalized() + if build.Version == "" { + build.Version = "dev" + } + if build.Commit == "" { + build.Commit = "unknown" + } + root := &cobra.Command{ + Use: "rc", + Short: "Runtime Conditions CLI", + Version: build.Version, + Annotations: map[string]string{"commit": build.Commit}, + Args: usageArgs(cobra.NoArgs), + ValidArgsFunction: cobra.NoFileCompletions, + SilenceErrors: true, + SilenceUsage: true, + CompletionOptions: cobra.CompletionOptions{DisableDefaultCmd: true}, + RunE: func(cmd *cobra.Command, _ []string) error { return cmd.Help() }, + } + root.SetIn(streams.In) + root.SetOut(streams.Out) + 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.InitDefaultHelpCmd() + for _, cmd := range root.Commands() { + if cmd.Name() == "help" { + // Find can return the nearest command with unrecognized arguments left over. + cmd.Args = usageArgs(func(_ *cobra.Command, args []string) error { + _, remaining, err := root.Find(args) + if err != nil { + return err + } + if len(remaining) != 0 { + return fmt.Errorf("unknown help topic %q", strings.Join(args, " ")) + } + return nil + }) + } + } + return root +} + +func usageArgs(validate cobra.PositionalArgs) cobra.PositionalArgs { + return func(cmd *cobra.Command, args []string) error { + if err := validate(cmd, args); err != nil { + return usageError{err} + } + return nil + } +} + +func newVersionCommand(build BuildInfo) *cobra.Command { + var output string + cmd := &cobra.Command{ + Use: "version", + Short: "Show CLI build information", + Args: usageArgs(cobra.NoArgs), + ValidArgsFunction: cobra.NoFileCompletions, + RunE: func(cmd *cobra.Command, _ []string) error { + switch output { + case "text": + _, err := fmt.Fprintf(cmd.OutOrStdout(), "rc %s (%s)\n", build.Version, build.Commit) + return err + case "yaml": + components, err := goProfilerComponents() + if err != nil { + return err + } + document := struct { + CLI BuildInfo `yaml:"cli"` + GoVersion string `yaml:"goVersion"` + Platform string `yaml:"platform"` + Components []goProfilerIdentity `yaml:"components"` + }{build, runtime.Version(), runtime.GOOS + "/" + runtime.GOARCH, components} + encoder := yaml.NewEncoder(cmd.OutOrStdout()) + encoder.SetIndent(2) + if err := encoder.Encode(document); err != nil { + return err + } + return encoder.Close() + default: + return usageError{fmt.Errorf("unsupported output format %q; use text or yaml", output)} + } + }, + } + cmd.Flags().StringVar(&output, "output", "text", "Output format: text or yaml") + return cmd +} + +func newCompletionCommand() *cobra.Command { + return &cobra.Command{ + Use: "completion ", + Short: "Generate a shell completion script", + ValidArgs: []string{"bash", "zsh", "fish", "powershell"}, + Args: usageArgs(cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs)), + RunE: func(cmd *cobra.Command, args []string) error { + out := cmd.OutOrStdout() + switch args[0] { + case "bash": + return cmd.Root().GenBashCompletionV2(out, true) + case "zsh": + return cmd.Root().GenZshCompletion(out) + case "fish": + return cmd.Root().GenFishCompletion(out, true) + default: // OnlyValidArgs has already restricted this to powershell. + return cmd.Root().GenPowerShellCompletionWithDesc(out) + } + }, + } +} diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go new file mode 100644 index 0000000..8b268f3 --- /dev/null +++ b/internal/cli/root_test.go @@ -0,0 +1,154 @@ +package cli + +import ( + "bytes" + "context" + "errors" + "io" + "runtime" + "strings" + "testing" + + "github.com/spf13/cobra" + "go.yaml.in/yaml/v3" +) + +func invoke(t *testing.T, args ...string) (int, string, string) { + t.Helper() + var out, errOut bytes.Buffer + code := Execute(context.Background(), args, BuildInfo{Version: "v0.1.0", Commit: "abc123"}, + IOStreams{Out: &out, ErrOut: &errOut}) + return code, out.String(), errOut.String() +} + +func TestHelp(t *testing.T) { + for _, args := range [][]string{nil, {"--help"}, {"help"}, {"help", "version"}} { + t.Run(strings.Join(args, " "), func(t *testing.T) { + code, out, errOut := invoke(t, args...) + if code != 0 || !strings.Contains(out, "Usage:") || errOut != "" { + t.Fatalf("code=%d stdout=%q stderr=%q", code, out, errOut) + } + }) + } +} + +func TestVersion(t *testing.T) { + for _, args := range [][]string{{"version"}, {"--version"}, {"version", "--output", "text"}} { + code, out, errOut := invoke(t, args...) + if code != 0 || out != "rc v0.1.0 (abc123)\n" || errOut != "" { + t.Fatalf("%v: code=%d stdout=%q stderr=%q", args, code, out, errOut) + } + } +} + +func TestVersionYAML(t *testing.T) { + code, out, errOut := invoke(t, "version", "--output", "yaml") + if code != 0 || errOut != "" { + t.Fatalf("code=%d stdout=%q stderr=%q", code, out, errOut) + } + var document struct { + CLI BuildInfo `yaml:"cli"` + GoVersion string `yaml:"goVersion"` + Platform string `yaml:"platform"` + Components []goProfilerIdentity `yaml:"components"` + } + decoder := yaml.NewDecoder(strings.NewReader(out)) + decoder.KnownFields(true) + if err := decoder.Decode(&document); err != nil { + t.Fatal(err) + } + if document.CLI != (BuildInfo{Version: "v0.1.0", Commit: "abc123"}) || + document.GoVersion != runtime.Version() || document.Platform != runtime.GOOS+"/"+runtime.GOARCH { + t.Fatalf("unexpected version document: %+v", document) + } + if err := decoder.Decode(new(any)); err != io.EOF { + t.Fatalf("unexpected trailing output: %v", err) + } +} + +func TestUsageFailures(t *testing.T) { + for _, args := range [][]string{ + {"missing"}, {"--unknown"}, {"version", "extra"}, + {"help", "missing"}, {"help", "version", "extra"}, + {"version", "--output", "json"}, {"version", "--output"}, + {"completion"}, {"completion", "unknown"}, {"completion", "bash", "extra"}, + } { + t.Run(strings.Join(args, " "), func(t *testing.T) { + code, out, errOut := invoke(t, args...) + if code != 2 || out != "" || strings.Count(errOut, "rc: ") != 1 || + !strings.Contains(errOut, "Usage:") { + t.Fatalf("code=%d stdout=%q stderr=%q", code, out, errOut) + } + }) + } +} + +func TestShellCompletions(t *testing.T) { + markers := map[string]string{ + "bash": "__start_rc", "zsh": "#compdef rc", + "fish": "complete -c rc", "powershell": "Register-ArgumentCompleter", + } + for shell, marker := range markers { + t.Run(shell, func(t *testing.T) { + code, out, errOut := invoke(t, "completion", shell) + if code != 0 || !strings.Contains(out, marker) || errOut != "" { + t.Fatalf("code=%d script contains marker=%t stderr=%q", code, strings.Contains(out, marker), errOut) + } + }) + } +} + +func TestCompletionCandidates(t *testing.T) { + code, out, errOut := invoke(t, "__complete", "completion", "") + if code != 0 || !strings.Contains(out, "bash\n") || !strings.Contains(out, "powershell\n") { + t.Fatalf("code=%d stdout=%q stderr=%q", code, out, errOut) + } +} + +type failingWriter struct{} + +func (failingWriter) Write([]byte) (int, error) { return 0, errors.New("output closed") } + +func TestOutputFailure(t *testing.T) { + for _, args := range [][]string{{"version"}, {"version", "--output", "yaml"}, {"completion", "bash"}} { + var errOut bytes.Buffer + code := Execute(context.Background(), args, BuildInfo{}, IOStreams{Out: failingWriter{}, ErrOut: &errOut}) + if code != 1 || strings.Count(errOut.String(), "rc: ") != 1 || + !strings.Contains(errOut.String(), "output closed") || strings.Contains(errOut.String(), "Usage:") { + t.Fatalf("%v: code=%d stderr=%q", args, code, errOut.String()) + } + } +} + +func TestCanceledContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + var out, errOut bytes.Buffer + code := Execute(ctx, []string{"version"}, BuildInfo{}, IOStreams{Out: &out, ErrOut: &errOut}) + if code != 130 || out.Len() != 0 || errOut.String() != "rc: context canceled\n" { + t.Fatalf("code=%d stdout=%q stderr=%q", code, out.String(), errOut.String()) + } +} + +func TestCommandIsolationAndInjectedStreams(t *testing.T) { + for _, version := range []string{"v0.1.0", "v0.2.0"} { + var out bytes.Buffer + root := NewRootCommand(BuildInfo{Version: version}, IOStreams{In: strings.NewReader("input"), Out: &out}) + root.AddCommand(&cobra.Command{ + Use: "echo", RunE: func(cmd *cobra.Command, _ []string) error { + _, err := io.Copy(cmd.OutOrStdout(), cmd.InOrStdin()) + return err + }, + }) + root.SetArgs([]string{"echo"}) + if err := root.ExecuteContext(context.Background()); err != nil || out.String() != "input" { + t.Fatalf("err=%v stdout=%q", err, out.String()) + } + if root.Version != version || root.PersistentFlags().HasFlags() { + t.Fatal("build metadata leaked or root flags became persistent") + } + } + if code := Execute(context.Background(), []string{"version"}, BuildInfo{}, IOStreams{}); code != 0 { + t.Fatalf("nil stream defaults: exit %d", code) + } +} diff --git a/scripts/build.go b/scripts/build.go new file mode 100644 index 0000000..c17ee25 --- /dev/null +++ b/scripts/build.go @@ -0,0 +1,80 @@ +//go:build ignore + +// Build the single rc executable, including its linked Go profiler and verified +// core schema. Nothing is downloaded or installed when the resulting rc runs. +// go run ./scripts/build.go --output dist/rc --version v0.1.0 --commit +// For an offline build, add --core-schema /path/to/the/approved/schema.yaml. +package main + +import ( + "crypto/sha256" + "encoding/base64" + "errors" + "flag" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "time" +) + +const coreSchemaURL = "https://github.com/runtimeconditions/spec/releases/download/v0.4.0/runtimeconditions.profile.schema.yaml" +const coreSchemaSHA256 = "96d430c7936fcf7334aa9613f63bb306592e07cf56fe4154af57933d2c1480e6" + +func main() { + if err := build(); err != nil { + fmt.Fprintln(os.Stderr, "build rc:", err) + os.Exit(1) + } +} + +func build() error { + output := flag.String("output", "dist/rc", "output executable path") + version := flag.String("version", "dev", "CLI release tag") + commit := flag.String("commit", "unknown", "CLI source commit") + schemaPath := flag.String("core-schema", "", "approved local core schema; otherwise fetched during build") + flag.Parse() + safe := regexp.MustCompile(`^[A-Za-z0-9.+-]+$`) + if flag.NArg() != 0 || !safe.MatchString(*version) || !safe.MatchString(*commit) { + return errors.New("expected safe version/commit values and no positional arguments") + } + var schema []byte + var err error + if *schemaPath != "" { + schema, err = os.ReadFile(*schemaPath) + } else { + client := &http.Client{Timeout: 60 * time.Second, CheckRedirect: func(request *http.Request, via []*http.Request) error { + if request.URL.Scheme != "https" || len(via) >= 10 { + return errors.New("invalid schema download redirect") + } + return nil + }} + response, fetchErr := client.Get(coreSchemaURL) + if fetchErr != nil { + return fetchErr + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return fmt.Errorf("schema download returned %s", response.Status) + } + schema, err = io.ReadAll(io.LimitReader(response.Body, 64*1024*1024+1)) + } + if err != nil { + return err + } + if fmt.Sprintf("%x", sha256.Sum256(schema)) != coreSchemaSHA256 { + return errors.New("core schema checksum mismatch") + } + if err := os.MkdirAll(filepath.Dir(*output), 0755); err != nil { + return err + } + linkerFlags := "-X main.version=" + *version + " -X main.commit=" + *commit + + " -X github.com/runtimeconditions/go-rc-profiler/extensioncheck.installedCoreSchemaBase64=" + + base64.StdEncoding.EncodeToString(schema) + command := exec.Command("go", "build", "-trimpath", "-ldflags", linkerFlags, "-o", *output, "./cmd/rc") + command.Stdin, command.Stdout, command.Stderr = os.Stdin, os.Stdout, os.Stderr + return command.Run() +} diff --git a/scripts/ci-smoke.go b/scripts/ci-smoke.go new file mode 100644 index 0000000..6edc1d5 --- /dev/null +++ b/scripts/ci-smoke.go @@ -0,0 +1,250 @@ +//go:build ignore + +package main + +import ( + "bytes" + "context" + "debug/buildinfo" + "flag" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "go.yaml.in/yaml/v3" +) + +const bindingModule = "runtimeconditions.io/x/rc/common" +const profilerModule = "github.com/runtimeconditions/go-rc-profiler" + +type options struct{ rc, fixture, version, commit, goVersion string } +type result struct{ out, stderr string } + +func main() { + var o options + flag.StringVar(&o.rc, "rc", "", "built rc executable") + flag.StringVar(&o.fixture, "fixture", "", "source workload fixture") + flag.StringVar(&o.version, "version", "ci", "expected CLI version") + flag.StringVar(&o.commit, "commit", "", "expected CLI commit") + flag.StringVar(&o.goVersion, "go-version", "go1.25.0", "expected workload toolchain") + flag.Parse() + if err := smoke(o); err != nil { + fmt.Fprintln(os.Stderr, "ci-smoke:", err) + os.Exit(1) + } + fmt.Println("PASS: isolated executable, linked profiler, generation, validation, and failure handling") +} + +func smoke(o options) error { + if o.rc == "" || o.fixture == "" || o.commit == "" || flag.NArg() != 0 { + return fmt.Errorf("--rc, --fixture, and --commit are required; positional arguments are unsupported") + } + tmp, err := os.MkdirTemp("", "rc-ci-smoke-*") + if err != nil { + return err + } + defer os.RemoveAll(tmp) + binary := filepath.Join(tmp, "rc") + if err := copyFile(o.rc, binary, 0755); err != nil { + return err + } + workload := filepath.Join(tmp, "workload") + if err := os.Mkdir(workload, 0755); err != nil { + return err + } + for _, name := range []string{"go.mod", "go.sum", "main.go"} { + if err := copyFile(filepath.Join(o.fixture, name), filepath.Join(workload, name), 0644); err != nil { + return err + } + } + env := append(os.Environ(), "GOTOOLCHAIN=local", "GOWORK=off", "GOFLAGS=-mod=readonly", + "GOPATH="+filepath.Join(tmp, "go"), "GOMODCACHE="+filepath.Join(tmp, "go", "pkg", "mod"), + "RC_GO_PROFILER_BIN="+filepath.Join(tmp, "must-not-be-used")) + toolchain, err := command(0, workload, env, "go", "env", "GOVERSION") + if err != nil { + return err + } + if strings.TrimSpace(toolchain.out) != o.goVersion { + return fmt.Errorf("analysis toolchain: got %q, want %s", toolchain.out, o.goVersion) + } + for _, args := range [][]string{{"mod", "download", "all"}, {"mod", "verify"}} { + if _, err := command(0, workload, env, "go", args...); err != nil { + return err + } + } + for _, name := range []string{"go.mod", "go.sum"} { + before, err := os.ReadFile(filepath.Join(o.fixture, name)) + if err != nil { + return err + } + after, err := os.ReadFile(filepath.Join(workload, name)) + if err != nil || !bytes.Equal(before, after) { + return fmt.Errorf("fixture dependency lock changed: %s: %v", name, err) + } + } + // All profile commands must work using only the dependencies fetched above. + env = append(env, "GOPROXY=off") + rc := func(want int, args ...string) (result, error) { + return command(want, workload, env, binary, args...) + } + build, err := buildinfo.ReadFile(binary) + if err != nil { + return err + } + dependencyVersion, dependencySum := "", "" + for _, dependency := range build.Deps { + if dependency.Path == profilerModule && dependency.Replace == nil { + dependencyVersion, dependencySum = dependency.Version, dependency.Sum + } + } + if dependencyVersion == "" || !strings.HasPrefix(dependencySum, "h1:") { + return fmt.Errorf("executable must link a published, checksummed Go profiler module") + } + release, err := rc(0, "--version") + if err != nil { + return err + } + if release.out != fmt.Sprintf("rc %s (%s)\n", o.version, o.commit) || release.stderr != "" { + return fmt.Errorf("incorrect linker metadata: %+v", release) + } + details, err := rc(0, "version", "--output", "yaml") + if err != nil { + return err + } + var identity struct { + CLI struct{ Version, Commit string } + Components []struct { + Name, Origin, Module, Version string + Sum string `yaml:"moduleSum"` + } + } + if err := yaml.Unmarshal([]byte(details.out), &identity); err != nil { + return err + } + if details.stderr != "" || identity.CLI.Version != o.version || identity.CLI.Commit != o.commit || + len(identity.Components) != 1 || identity.Components[0].Name != "go-rc-profiler" || + identity.Components[0].Origin != "linked" || identity.Components[0].Module != profilerModule || + identity.Components[0].Version != dependencyVersion || identity.Components[0].Sum != dependencySum { + return fmt.Errorf("incorrect linked component metadata: %s", details.out) + } + var expected string + output := filepath.Join(workload, "profile.yaml") + for _, route := range [][]string{{"profile"}, {"profile", "go"}} { + args := append(append([]string{}, route...), "generate", "--dir", workload, "--name", "ci-fixture", + "--workload-uri", "https://example.com/ci-fixture", "--workload-version", "1.0.0") + generated, err := rc(0, args...) + if err != nil { + return err + } + if generated.stderr != "" { + return fmt.Errorf("generation stderr: %s", generated.stderr) + } + if err := checkProfile(generated.out); err != nil { + return err + } + if expected != "" && expected != generated.out { + return fmt.Errorf("default and explicit Go routes produced different profiles") + } + expected = generated.out + written, err := rc(0, append(args, "--out", output)...) + if err != nil { + return err + } + data, err := os.ReadFile(output) + if err != nil || string(data) != expected || written.out != "" || written.stderr != "" { + return fmt.Errorf("incorrect --out result: %+v: %v", written, err) + } + for _, validation := range [][]string{ + {"validate-extension", "--dir", workload, "--package", bindingModule}, + {"validate-extensions", "--dir", workload}, + } { + if _, err := rc(0, append(append([]string{}, route...), validation...)...); err != nil { + return err + } + } + } + source, err := os.ReadFile(filepath.Join(workload, "main.go")) + if err != nil { + return err + } + invalid := strings.Replace(string(source), "common.KeyValueEngineRedis", `common.KeyValueEngine("invalid-ci-engine")`, 1) + if err := os.WriteFile(filepath.Join(workload, "main.go"), []byte(invalid), 0644); err != nil { + return err + } + for _, route := range [][]string{{"profile"}, {"profile", "go"}} { + failed, err := rc(1, append(append([]string{}, route...), "generate", "--dir", workload, "--out", output)...) + if err != nil { + return err + } + data, err := os.ReadFile(output) + if err != nil || string(data) != expected || failed.out != "" || + !strings.Contains(failed.stderr, "invalid-ci-engine") || strings.Contains(failed.stderr, "Usage:") { + return fmt.Errorf("invalid declaration must fail without replacing output: %+v: %v", failed, err) + } + } + leftovers, err := filepath.Glob(filepath.Join(workload, ".runtimeconditions-profile-*")) + if err != nil || len(leftovers) != 0 { + return fmt.Errorf("temporary output files remain: %v: %v", leftovers, err) + } + return nil +} + +func checkProfile(data string) error { + var profile struct { + APIVersion string `yaml:"apiVersion"` + Kind string + Metadata struct{ Name string } + Workload struct{ URI, Version string } + Extensions []string + Conditions []struct { + Kind string + Interface map[string]string + } + } + if err := yaml.Unmarshal([]byte(data), &profile); err != nil { + return err + } + if profile.APIVersion != "runtimeconditions.io/v1alpha1" || profile.Kind != "RuntimeConditionsProfile" || + profile.Metadata.Name != "ci-fixture" || profile.Workload.URI != "https://example.com/ci-fixture" || + profile.Workload.Version != "1.0.0" || len(profile.Extensions) != 1 || + profile.Extensions[0] != "https://runtimeconditions.io/extensions/common-integrations/v1alpha1/runtimeconditions.extension.yaml" || + len(profile.Conditions) != 1 || profile.Conditions[0].Kind != "cache" || + profile.Conditions[0].Interface["type"] != "key_value" || profile.Conditions[0].Interface["engine"] != "redis" { + return fmt.Errorf("unexpected generated profile:\n%s", data) + } + return nil +} + +func command(want int, dir string, env []string, executable string, args ...string) (result, error) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + cmd := exec.CommandContext(ctx, executable, args...) + cmd.Dir, cmd.Env, cmd.WaitDelay = dir, env, 5*time.Second + var out, stderr bytes.Buffer + cmd.Stdout, cmd.Stderr = &out, &stderr + err := cmd.Run() + code := 0 + if err != nil { + exit, ok := err.(*exec.ExitError) + if !ok { + return result{}, fmt.Errorf("%s %v: %w", executable, args, err) + } + code = exit.ExitCode() + } + got := result{out.String(), stderr.String()} + if code != want { + return got, fmt.Errorf("%s %v: exit %d, want %d\nstdout: %s\nstderr: %s", executable, args, code, want, got.out, got.stderr) + } + return got, nil +} + +func copyFile(source, target string, mode os.FileMode) error { + data, err := os.ReadFile(source) + if err != nil { + return err + } + return os.WriteFile(target, data, mode) +} diff --git a/scripts/stage-go-profiler.go b/scripts/stage-go-profiler.go new file mode 100644 index 0000000..d6f4c49 --- /dev/null +++ b/scripts/stage-go-profiler.go @@ -0,0 +1,193 @@ +//go:build ignore + +// Stage a verified published Go profiler without rebuilding it. +// From the repository root: +// go run ./scripts/stage-go-profiler.go --archive /path/to/release.tar.gz --prefix /new/staging/root +// Use --platform linux/arm64 for cross-packaging and --layout linux for Debian paths. +// Download the archive URL from distribution/components.lock.yaml before running this helper. +package main + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "crypto/sha256" + "errors" + "flag" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + + "go.yaml.in/yaml/v3" +) + +type asset struct { + URL string `yaml:"url"` + ArchiveSHA256 string `yaml:"archiveSHA256"` + ExecutableSHA256 string `yaml:"executableSHA256"` +} + +type componentLock struct { + APIVersion string `yaml:"apiVersion"` + GoProfiler struct { + Version string `yaml:"version"` + Commit string `yaml:"commit"` + Protocol string `yaml:"protocol"` + CoreSchema map[string]string `yaml:"coreSchema"` + Platforms map[string]asset `yaml:"platforms"` + } `yaml:"goProfiler"` +} + +func main() { + if err := stage(); err != nil { + fmt.Fprintln(os.Stderr, "stage Go profiler:", err) + os.Exit(1) + } +} + +func stage() error { + lockPath := flag.String("lock", "distribution/components.lock.yaml", "reviewed component lock") + archivePath := flag.String("archive", "", "previously downloaded release archive") + prefix := flag.String("prefix", "", "new staging directory; must not exist") + platform := flag.String("platform", runtime.GOOS+"/"+runtime.GOARCH, "target OS/architecture") + layout := flag.String("layout", "archive", "archive (also Homebrew) or linux (Debian)") + flag.Parse() + if *archivePath == "" || *prefix == "" || flag.NArg() != 0 { + return errors.New("--archive and --prefix are required; no positional arguments") + } + toolDir := "libexec" + if *layout == "linux" { + toolDir = "lib" + } else if *layout != "archive" { + return errors.New("--layout must be archive or linux") + } + lockBytes, err := os.ReadFile(*lockPath) + if err != nil { + return err + } + if len(lockBytes) > 64*1024 { + return errors.New("component lock exceeds 64 KiB") + } + var lock componentLock + decoder := yaml.NewDecoder(bytes.NewReader(lockBytes)) + decoder.KnownFields(true) + if err := decoder.Decode(&lock); err != nil { + return err + } + if decoder.Decode(new(any)) != io.EOF || lock.APIVersion != "runtimeconditions.io/cli-components/v1alpha1" || + lock.GoProfiler.Protocol != "go-profiler-argv/v1" || lock.GoProfiler.Version == "" || lock.GoProfiler.Commit == "" { + return errors.New("unsupported or incomplete component lock") + } + target, ok := lock.GoProfiler.Platforms[*platform] + if !ok { + return fmt.Errorf("no locked profiler for %s", *platform) + } + archive, err := os.Open(*archivePath) + if err != nil { + return err + } + defer archive.Close() + stat, err := archive.Stat() + if err != nil { + return err + } + if !stat.Mode().IsRegular() || stat.Size() > 64*1024*1024 { + return errors.New("archive must be a regular file no larger than 64 MiB") + } + digest := sha256.New() + if _, err := io.Copy(digest, archive); err != nil { + return err + } + if fmt.Sprintf("%x", digest.Sum(nil)) != target.ArchiveSHA256 { + return errors.New("release archive checksum mismatch") + } + if _, err := archive.Seek(0, io.SeekStart); err != nil { + return err + } + compressed, err := gzip.NewReader(archive) + if err != nil { + return err + } + defer compressed.Close() + reader := tar.NewReader(io.LimitReader(compressed, 130*1024*1024)) + files := make(map[string][]byte) + for { + header, err := reader.Next() + if err == io.EOF { + break + } + if err != nil { + return err + } + limit := int64(128 * 1024 * 1024) + if header.Name == "README.md" { + limit = 1024 * 1024 + } else if header.Name != "go-rc-profiler" { + return fmt.Errorf("unexpected archive entry %q", header.Name) + } + if header.Typeflag != tar.TypeReg || header.Size > limit || files[header.Name] != nil { + return fmt.Errorf("invalid or duplicate archive entry %q", header.Name) + } + data, err := io.ReadAll(reader) + if err != nil { + return err + } + files[header.Name] = data + } + binary := files["go-rc-profiler"] + if len(binary) == 0 || len(files["README.md"]) == 0 { + return errors.New("archive must contain the profiler and README.md") + } + if fmt.Sprintf("%x", sha256.Sum256(binary)) != target.ExecutableSHA256 { + return errors.New("extracted profiler checksum mismatch") + } + destination, err := filepath.Abs(*prefix) + if err != nil { + return err + } + if _, err := os.Lstat(destination); !errors.Is(err, os.ErrNotExist) { + return errors.New("--prefix must name a new directory") + } + if err := os.MkdirAll(filepath.Dir(destination), 0755); err != nil { + return err + } + temporary, err := os.MkdirTemp(filepath.Dir(destination), ".rc-go-stage-") + if err != nil { + return err + } + defer os.RemoveAll(temporary) + outputs := map[string][]byte{ + filepath.Join(toolDir, "rc-cli", "go-rc-profiler"): binary, + "share/rc-cli/components.lock.yaml": lockBytes, + "share/doc/rc-cli/go-profiler/README.md": files["README.md"], + } + for relative, data := range outputs { + path := filepath.Join(temporary, relative) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + mode := os.FileMode(0644) + if filepath.Base(path) == "go-rc-profiler" { + mode = 0755 + } + if err := os.WriteFile(path, data, mode); err != nil { + return err + } + } + if *platform == runtime.GOOS+"/"+runtime.GOARCH { + path := filepath.Join(temporary, toolDir, "rc-cli", "go-rc-profiler") + banner, err := exec.Command(path, "--version").Output() + expected := fmt.Sprintf("go-rc-profiler %s (%s)\n", lock.GoProfiler.Version, lock.GoProfiler.Commit) + if err != nil || string(banner) != expected { + return errors.New("profiler version does not match the locked release") + } + } + if err := os.Rename(temporary, destination); err != nil { + return err + } + fmt.Println(destination) + return nil +} diff --git a/testdata/ci/go-project/go.mod b/testdata/ci/go-project/go.mod new file mode 100644 index 0000000..ced99c5 --- /dev/null +++ b/testdata/ci/go-project/go.mod @@ -0,0 +1,5 @@ +module example.com/rc-cli-ci-fixture + +go 1.25.0 + +require runtimeconditions.io/x/rc/common v0.1.0 diff --git a/testdata/ci/go-project/go.sum b/testdata/ci/go-project/go.sum new file mode 100644 index 0000000..6f58144 --- /dev/null +++ b/testdata/ci/go-project/go.sum @@ -0,0 +1,2 @@ +runtimeconditions.io/x/rc/common v0.1.0 h1:65dv8adIbg7WQR1YbcTx6QfaEc4UBncuJ8GP0Z2/VV4= +runtimeconditions.io/x/rc/common v0.1.0/go.mod h1:nlO8VZnPbfC2ohtbJwkhv7AEqG5KEWMPCDHCpOVMrkM= diff --git a/testdata/ci/go-project/main.go b/testdata/ci/go-project/main.go new file mode 100644 index 0000000..138e045 --- /dev/null +++ b/testdata/ci/go-project/main.go @@ -0,0 +1,14 @@ +package main + +import "runtimeconditions.io/x/rc/common" + +var _ = common.Cache(common.KeyValue{ + Engine: &[]common.KeyValueEngine{common.KeyValueEngineRedis}[0], +}) + +// Successful profiling must never execute the workload or its initializers. +func init() { + panic("rc CI fixture must only be analyzed, never executed") +} + +func main() {}