From 29c9cfc59da101ac10a64c272bd4443a25889dc6 Mon Sep 17 00:00:00 2001 From: Colin Lacy Date: Sat, 7 Nov 2026 07:09:39 +0000 Subject: [PATCH 01/10] adds preparation files --- AGENTS.md | 9 ++ CLI-IMPLEMENTATION.md | 236 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 245 insertions(+) create mode 100644 AGENTS.md create mode 100644 CLI-IMPLEMENTATION.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..703a2a0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,9 @@ +DO NOT CREATE OR EDIT MORE THAN 5 FILES AT A TIME WITHOUT CLEAR APPROVAL. IF YOU CANNOT TELL ME, AHEAD OF TIME, WHICH 5 FILES YOU ARE GOING TO CREATE OR EDIT, YOU ARE DOING IT WRONG. YOU ARE SUPPOSED TO BE THE LATEST FRONTIER MODEL, CAPABLE OF REASONING AT INSANE SPEEDS WITH MASSIVE CONTEXT LENGTH. YOU NEED TO PLAN AHEAD, AND BE CLEAR ABOUT WHAT YOU'RE GOING TO DO. + +NO FILE SHOULD HAVE MORE THAN 250 LINES OF EDITS DURING A TURN. THAT MEANS THAT, AT MOST, IN A COMBINED 5 FILES, YOU MAY NOT EDIT MORE THAN 1250 LINES. + +At the end of each turn, you need to have created COMPLETE functions and objects. DO NOT stop in the middle of a block of code. It's ok, if the software as a whole isn't working YET, but you need to indicate to me what you've done so far, and what your next move is. + +All of your output MUST be concise and to the point. You MUST NOT use any ambiguous words or introduce new concepts to the discussion without explaining them. + +Each turn MUST end with an explanation of what your next move will be, and indicate to me what input you will need before you can proceed. \ No newline at end of file diff --git a/CLI-IMPLEMENTATION.md b/CLI-IMPLEMENTATION.md new file mode 100644 index 0000000..8bfab98 --- /dev/null +++ b/CLI-IMPLEMENTATION.md @@ -0,0 +1,236 @@ +# 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. +- Build rc with source/version metadata, assemble locked upstream bytes once, and create a complete inventory. +- 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). From 9e96ed6e66255a63786019f7bd001b4dfd801af0 Mon Sep 17 00:00:00 2001 From: Colin Lacy Date: Sat, 7 Nov 2026 07:21:53 +0000 Subject: [PATCH 02/10] adds cli root --- cmd/rc/main.go | 29 ++++++ go.mod | 15 ++++ go.sum | 12 +++ internal/cli/root.go | 180 ++++++++++++++++++++++++++++++++++++++ internal/cli/root_test.go | 153 ++++++++++++++++++++++++++++++++ 5 files changed, 389 insertions(+) create mode 100644 cmd/rc/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/cli/root.go create mode 100644 internal/cli/root_test.go 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/go.mod b/go.mod new file mode 100644 index 0000000..c883afd --- /dev/null +++ b/go.mod @@ -0,0 +1,15 @@ +module github.com/runtimeconditions/rc-cli + +go 1.25.0 + +toolchain go1.26.5 + +require ( + github.com/spf13/cobra v1.10.2 + go.yaml.in/yaml/v3 v3.0.4 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e63b363 --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +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= +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= diff --git a/internal/cli/root.go b/internal/cli/root.go new file mode 100644 index 0000000..f9eabd8 --- /dev/null +++ b/internal/cli/root.go @@ -0,0 +1,180 @@ +// 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()) + 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": + document := struct { + CLI BuildInfo `yaml:"cli"` + GoVersion string `yaml:"goVersion"` + Platform string `yaml:"platform"` + }{build, runtime.Version(), runtime.GOOS + "/" + runtime.GOARCH} + 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..bfa37d8 --- /dev/null +++ b/internal/cli/root_test.go @@ -0,0 +1,153 @@ +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"` + } + 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) + } +} From 7aee3fd1b4f499216a2fd165d5ae2d09277c553a Mon Sep 17 00:00:00 2001 From: Colin Lacy Date: Sat, 7 Nov 2026 07:28:06 +0000 Subject: [PATCH 03/10] adds release specifics --- CLI-IMPLEMENTATION.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/CLI-IMPLEMENTATION.md b/CLI-IMPLEMENTATION.md index 8bfab98..2c66e01 100644 --- a/CLI-IMPLEMENTATION.md +++ b/CLI-IMPLEMENTATION.md @@ -187,7 +187,19 @@ Windows and RPM channels follow after the initial package contract; do not adver ## 9. Build, release, and package channels - Tag rc-cli independently; pin component locks, Cobra, build Go, packaging tools, and Actions by full SHA. -- Build rc with source/version metadata, assemble locked upstream bytes once, and create a complete inventory. +- 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. From 808b1b668c3ce84a0fd32ef51d8f9132e9798eb9 Mon Sep 17 00:00:00 2001 From: Colin Lacy Date: Sat, 7 Nov 2026 07:47:52 +0000 Subject: [PATCH 04/10] begins profiler implementation --- internal/cli/root.go | 19 ++- internal/cli/root_test.go | 250 +++++++++++++++++++++++++++++++++++++- 2 files changed, 261 insertions(+), 8 deletions(-) diff --git a/internal/cli/root.go b/internal/cli/root.go index f9eabd8..fb96dd4 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -55,6 +55,10 @@ func Execute(ctx context.Context, args []string, build BuildInfo, streams IOStre if err == nil { return 0 } + var backendExit profilerExitError + if errors.As(err, &backendExit) { + return int(backendExit) + } fmt.Fprintf(streams.ErrOut, "rc: %v\n", err) if errors.Is(err, context.Canceled) { return 130 @@ -95,7 +99,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()) + root.AddCommand(newVersionCommand(build), newCompletionCommand(), newProfileCommand()) root.InitDefaultHelpCmd() for _, cmd := range root.Commands() { if cmd.Name() == "help" { @@ -137,11 +141,16 @@ func newVersionCommand(build BuildInfo) *cobra.Command { _, 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"` - }{build, runtime.Version(), runtime.GOOS + "/" + runtime.GOARCH} + 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 { diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index bfa37d8..e66dd82 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -3,11 +3,21 @@ package cli import ( "bytes" "context" + "crypto/sha256" + "encoding/json" "errors" + "fmt" "io" + "os" + "os/exec" + "path/filepath" + "reflect" "runtime" + "strconv" "strings" + "syscall" "testing" + "time" "github.com/spf13/cobra" "go.yaml.in/yaml/v3" @@ -47,9 +57,10 @@ func TestVersionYAML(t *testing.T) { 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"` + 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) @@ -151,3 +162,236 @@ func TestCommandIsolationAndInjectedStreams(t *testing.T) { t.Fatalf("nil stream defaults: exit %d", code) } } + +func TestMain(m *testing.M) { + if os.Getenv("RC_CLI_TEST_HELPER") == "1" { + switch os.Getenv("RC_CLI_TEST_HELPER_MODE") { + case "exit": + fmt.Fprintln(os.Stderr, "backend diagnostic") + os.Exit(23) + case "signal": + _ = syscall.Kill(os.Getpid(), syscall.SIGTERM) + case "wait": + child := exec.Command(os.Args[0]) + child.Env = append(os.Environ(), "RC_CLI_TEST_HELPER_MODE=sleep") + if err := child.Start(); err != nil { + panic(err) + } + if err := os.WriteFile(os.Getenv("RC_CLI_TEST_READY"), []byte(strconv.Itoa(child.Process.Pid)), 0600); err != nil { + panic(err) + } + time.Sleep(30 * time.Second) + case "sleep": + time.Sleep(30 * time.Second) + default: + input, _ := io.ReadAll(os.Stdin) + cwd, _ := os.Getwd() + _ = json.NewEncoder(os.Stdout).Encode(map[string]any{ + "args": os.Args[1:], "input": string(input), "cwd": cwd, "value": os.Getenv("RC_CLI_TEST_VALUE"), + }) + } + os.Exit(0) + } + os.Exit(m.Run()) +} + +func fakeGoProfiler(t *testing.T) string { + t.Helper() + self, err := os.Executable() + if err != nil { + t.Fatal(err) + } + binary, err := os.ReadFile(self) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "go profiler") + if err := os.WriteFile(path, binary, 0755); err != nil { + t.Fatal(err) + } + t.Setenv("RC_GO_PROFILER_BIN", path) + t.Setenv("RC_CLI_TEST_HELPER", "1") + t.Setenv("RC_CLI_TEST_HELPER_MODE", "args") + t.Setenv("GORACE", "atexit_sleep_ms=0") + return path +} + +func TestProfileArgumentAndStreamPreservation(t *testing.T) { + fakeGoProfiler(t) + t.Setenv("RC_CLI_TEST_VALUE", "inherited") + cwd, _ := os.Getwd() + cases := [][]string{ + {"generate", "-dir", "path with spaces", "--name", "first", "--name", "second", "--", "tail"}, + {"go", "generate", "-dir", "path with spaces", "--name", "first", "--name", "second", "--", "tail"}, + {"go", "--help"}, {"go", "--version"}, {"--version"}, {"version"}, + {"validate-extension", "-package", "example/binding"}, {"validate-extensions"}, {"go"}, + } + for _, args := range cases { + var out, errOut bytes.Buffer + code := Execute(context.Background(), append([]string{"profile"}, args...), BuildInfo{}, + IOStreams{In: strings.NewReader("input bytes"), Out: &out, ErrOut: &errOut}) + var got struct { + Args []string + Input, Cwd, Value string + } + if err := json.Unmarshal(out.Bytes(), &got); err != nil { + t.Fatalf("%v: %v; %q", args, err, out.String()) + } + want := args + if args[0] == "go" { + want = args[1:] + } + if code != 0 || errOut.Len() != 0 || !reflect.DeepEqual(got.Args, want) || + got.Input != "input bytes" || got.Cwd != cwd || got.Value != "inherited" { + t.Fatalf("%v: code=%d got=%+v stderr=%q", args, code, got, errOut.String()) + } + } +} + +func TestProfileHostHelpAndUnknownLanguage(t *testing.T) { + t.Setenv("RC_GO_PROFILER_BIN", "/missing/profiler") + for _, args := range [][]string{{"profile"}, {"profile", "--help"}, {"profile", "-h"}, {"help", "profile"}} { + code, out, errOut := invoke(t, args...) + if code != 0 || !strings.Contains(out, "rc profile go") || errOut != "" { + t.Fatalf("%v: code=%d stdout=%q stderr=%q", args, code, out, errOut) + } + } + code, out, errOut := invoke(t, "profile", "java", "generate") + if code != 2 || out != "" || !strings.Contains(errOut, "unsupported profiler language") { + t.Fatalf("code=%d stdout=%q stderr=%q", code, out, errOut) + } + code, out, _ = invoke(t, "__complete", "profile", "") + if code != 0 || !strings.Contains(out, "go\n") { + t.Fatalf("code=%d stdout=%q", code, out) + } +} + +func TestProfileExitStatus(t *testing.T) { + fakeGoProfiler(t) + t.Setenv("RC_CLI_TEST_HELPER_MODE", "exit") + code, out, errOut := invoke(t, "profile", "go", "--unknown") + if code != 23 || out != "" || errOut != "backend diagnostic\n" { + t.Fatalf("code=%d stdout=%q stderr=%q", code, out, errOut) + } + t.Setenv("RC_CLI_TEST_HELPER_MODE", "signal") + code, _, _ = invoke(t, "profile", "version") + if code != 143 { + t.Fatalf("signaled backend: exit=%d", code) + } +} + +func TestProfileCancellationStopsDescendants(t *testing.T) { + fakeGoProfiler(t) + t.Setenv("RC_CLI_TEST_HELPER_MODE", "wait") + ready := filepath.Join(t.TempDir(), "child.pid") + t.Setenv("RC_CLI_TEST_READY", ready) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan int, 1) + go func() { done <- Execute(ctx, []string{"profile", "generate"}, BuildInfo{}, IOStreams{}) }() + var childPID int + deadline := time.Now().Add(5 * time.Second) + for childPID == 0 && time.Now().Before(deadline) { + if data, err := os.ReadFile(ready); err == nil { + childPID, _ = strconv.Atoi(string(data)) + } + time.Sleep(10 * time.Millisecond) + } + cancel() + select { + case code := <-done: + if code != 130 { + t.Fatalf("canceled profiler: exit=%d", code) + } + case <-time.After(4 * time.Second): + t.Fatal("profiler cancellation timed out") + } + if childPID == 0 { + t.Fatal("profiler did not start its child") + } + defer syscall.Kill(childPID, syscall.SIGKILL) + for i := 0; i < 100; i++ { + if err := syscall.Kill(childPID, 0); errors.Is(err, syscall.ESRCH) { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal("profiler descendant survived cancellation") +} + +func TestGoProfilerOverrideAndVersionIdentity(t *testing.T) { + path := fakeGoProfiler(t) + path, _ = filepath.EvalSymlinks(path) + code, out, errOut := invoke(t, "version", "--output", "yaml") + var doc struct { + Components []goProfilerIdentity `yaml:"components"` + } + if err := yaml.Unmarshal([]byte(out), &doc); err != nil { + t.Fatal(err) + } + if code != 0 || errOut != "" || len(doc.Components) != 1 || + doc.Components[0].Origin != "override" || doc.Components[0].Path != path || len(doc.Components[0].SHA256) != 64 { + t.Fatalf("code=%d document=%+v stderr=%q", code, doc, errOut) + } + t.Setenv("RC_GO_PROFILER_BIN", "relative") + if code, _, _ := invoke(t, "profile", "generate"); code != 1 { + t.Fatalf("relative override: exit=%d", code) + } + self, _ := os.Executable() + t.Setenv("RC_GO_PROFILER_BIN", self) + if code, _, errOut := invoke(t, "profile", "generate"); code != 1 || !strings.Contains(errOut, "cannot resolve to the rc executable") { + t.Fatalf("recursive override: code=%d stderr=%q", code, errOut) + } +} + +func TestBundledGoProfilerDiscoveryAndIntegrity(t *testing.T) { + for _, layout := range []string{"libexec", "lib"} { + t.Run(layout, func(t *testing.T) { + prefix := t.TempDir() + self := filepath.Join(prefix, "bin", "rc") + binary := filepath.Join(prefix, layout, "rc-cli", "go-rc-profiler") + manifest := filepath.Join(prefix, "share", "rc-cli", "components.lock.yaml") + for _, path := range []string{self, binary, manifest} { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(self, []byte("cli"), 0755); err != nil { + t.Fatal(err) + } + data := []byte("profiler") + if err := os.WriteFile(binary, data, 0755); err != nil { + t.Fatal(err) + } + lockBytes, err := os.ReadFile("../../distribution/components.lock.yaml") + if err != nil { + t.Fatal(err) + } + var lock goProfilerLock + if err := yaml.Unmarshal(lockBytes, &lock); err != nil { + t.Fatal(err) + } + key := runtime.GOOS + "/" + runtime.GOARCH + asset := lock.GoProfiler.Platforms[key] + asset.ExecutableSHA256 = fmt.Sprintf("%x", sha256.Sum256(data)) + lock.GoProfiler.Platforms[key] = asset + lockBytes, err = yaml.Marshal(lock) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(manifest, lockBytes, 0644); err != nil { + t.Fatal(err) + } + found, err := locateGoProfiler(self, "") + if err != nil || found.Origin != "bundled" || found.Version != lock.GoProfiler.Version { + t.Fatalf("identity=%+v err=%v", found, err) + } + if err := os.WriteFile(binary, []byte("tampered"), 0755); err != nil { + t.Fatal(err) + } + if _, err := locateGoProfiler(self, ""); err == nil || !strings.Contains(err.Error(), "checksum mismatch") { + t.Fatalf("tampered bundle accepted: %v", err) + } + }) + } +} From 4ab94c700c23ef67bacfad9f9abb0063a33d1691 Mon Sep 17 00:00:00 2001 From: Colin Lacy Date: Sat, 7 Nov 2026 07:52:49 +0000 Subject: [PATCH 05/10] begins profiler implementation --- distribution/components.lock.yaml | 27 ++++ internal/cli/profile.go | 236 ++++++++++++++++++++++++++++++ scripts/stage-go-profiler.go | 193 ++++++++++++++++++++++++ 3 files changed, 456 insertions(+) create mode 100644 distribution/components.lock.yaml create mode 100644 internal/cli/profile.go create mode 100644 scripts/stage-go-profiler.go 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/internal/cli/profile.go b/internal/cli/profile.go new file mode 100644 index 0000000..2b19f4c --- /dev/null +++ b/internal/cli/profile.go @@ -0,0 +1,236 @@ +package cli + +import ( + "context" + "crypto/sha256" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "syscall" + "time" + + "github.com/spf13/cobra" + "go.yaml.in/yaml/v3" +) + +type profilerExitError int + +func (e profilerExitError) Error() string { return fmt.Sprintf("profiler exited with status %d", e) } + +type goProfilerIdentity struct { + Name string `yaml:"name"` + Path string `yaml:"path"` + Origin string `yaml:"origin"` + Version string `yaml:"version,omitempty"` + Commit string `yaml:"commit,omitempty"` + SHA256 string `yaml:"sha256"` +} + +type goProfilerAsset struct { + URL string `yaml:"url"` + ArchiveSHA256 string `yaml:"archiveSHA256"` + ExecutableSHA256 string `yaml:"executableSHA256"` +} + +type goProfilerLock 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]goProfilerAsset `yaml:"platforms"` + } `yaml:"goProfiler"` +} + +var errGoProfilerMissing = errors.New("Go profiler unavailable; install the complete rc-cli bundle or set RC_GO_PROFILER_BIN to an absolute executable path") + +func newProfileCommand() *cobra.Command { + return &cobra.Command{ + Use: "profile [go] [profiler arguments...]", + Short: "Generate profiles with the Go profiler by default", + Long: "Generate profiles with the bundled Go profiler. Go analysis requires a working Go toolchain.\n" + + "Arguments after go, or an unqualified Go operation, pass unchanged to the profiler.", + Example: " rc profile generate -dir ./service\n rc profile go generate -dir ./service\n rc profile go --help", + DisableFlagParsing: true, + Args: cobra.ArbitraryArgs, + ValidArgsFunction: func(_ *cobra.Command, args []string, prefix string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 && strings.HasPrefix("go", prefix) { + return []string{"go"}, cobra.ShellCompDirectiveNoFileComp + } + return nil, cobra.ShellCompDirectiveNoFileComp + }, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 || (len(args) == 1 && (args[0] == "--help" || args[0] == "-h")) { + return cmd.Help() + } + switch args[0] { + case "go": + args = args[1:] + case "generate", "validate-extension", "validate-extensions", "version": + default: + if !strings.HasPrefix(args[0], "-") { + return usageError{fmt.Errorf("unsupported profiler language %q; Go is included by default", args[0])} + } + } + profiler, err := findGoProfiler() + if err != nil { + return err + } + return runGoProfiler(cmd.Context(), profiler.Path, args, + IOStreams{In: cmd.InOrStdin(), Out: cmd.OutOrStdout(), ErrOut: cmd.ErrOrStderr()}) + }, + } +} + +func findGoProfiler() (goProfilerIdentity, error) { + self, err := os.Executable() + if err != nil { + return goProfilerIdentity{}, err + } + return locateGoProfiler(self, os.Getenv("RC_GO_PROFILER_BIN")) +} + +func locateGoProfiler(self, override string) (goProfilerIdentity, error) { + self, err := filepath.EvalSymlinks(self) + if err != nil { + return goProfilerIdentity{}, err + } + if override != "" { + if !filepath.IsAbs(override) { + return goProfilerIdentity{}, errors.New("RC_GO_PROFILER_BIN must be an absolute path") + } + return identifyGoProfiler(override, self, "override") + } + prefix := filepath.Dir(filepath.Dir(self)) + paths := []string{filepath.Join(prefix, "libexec", "rc-cli", "go-rc-profiler"), filepath.Join(prefix, "lib", "rc-cli", "go-rc-profiler")} + manifest := filepath.Join(prefix, "share", "rc-cli", "components.lock.yaml") + file, err := os.Open(manifest) + if err == nil { + defer file.Close() + decoder := yaml.NewDecoder(io.LimitReader(file, 64*1024+1)) + decoder.KnownFields(true) + var lock goProfilerLock + if stat, statErr := file.Stat(); statErr != nil || stat.Size() > 64*1024 { + return goProfilerIdentity{}, errors.New("invalid or oversized component lock") + } + if err = decoder.Decode(&lock); err != nil { + return goProfilerIdentity{}, fmt.Errorf("component lock: %w", 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 goProfilerIdentity{}, errors.New("unsupported or incomplete component lock") + } + asset, ok := lock.GoProfiler.Platforms[runtime.GOOS+"/"+runtime.GOARCH] + if !ok { + return goProfilerIdentity{}, errors.New("component lock does not support this platform") + } + for _, path := range paths { + if _, err := os.Lstat(path); errors.Is(err, os.ErrNotExist) { + continue + } + identity, err := identifyGoProfiler(path, self, "bundled") + if err != nil { + return identity, err + } + if identity.SHA256 != asset.ExecutableSHA256 { + return identity, errors.New("bundled Go profiler checksum mismatch; reinstall rc-cli") + } + identity.Version, identity.Commit = lock.GoProfiler.Version, lock.GoProfiler.Commit + return identity, nil + } + return goProfilerIdentity{}, errors.New("component lock exists but bundled Go profiler is missing") + } + if !errors.Is(err, os.ErrNotExist) { + return goProfilerIdentity{}, err + } + for _, path := range paths { + if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) { + return goProfilerIdentity{}, errors.New("bundled Go profiler has no component lock") + } + } + path, err := exec.LookPath("go-rc-profiler") + if err != nil { + return goProfilerIdentity{}, errGoProfilerMissing + } + return identifyGoProfiler(path, self, "path") +} + +func identifyGoProfiler(path, self, origin string) (goProfilerIdentity, error) { + identity := goProfilerIdentity{Name: "go-rc-profiler", Origin: origin} + path, err := filepath.EvalSymlinks(path) + if err != nil { + return identity, err + } + path, err = filepath.Abs(path) + if err != nil { + return identity, err + } + if origin == "bundled" { + relative, err := filepath.Rel(filepath.Dir(filepath.Dir(self)), path) + if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(os.PathSeparator)) { + return identity, errors.New("bundled Go profiler escapes the installation prefix") + } + } + stat, err := os.Stat(path) + if err != nil { + return identity, err + } + if !stat.Mode().IsRegular() || stat.Mode().Perm()&0111 == 0 { + return identity, errors.New("Go profiler must be a regular executable file") + } + if current, err := os.Stat(self); err != nil || os.SameFile(stat, current) { + return identity, errors.New("Go profiler cannot resolve to the rc executable") + } + file, err := os.Open(path) + if err != nil { + return identity, err + } + defer file.Close() + hash := sha256.New() + if _, err := io.Copy(hash, file); err != nil { + return identity, err + } + identity.Path, identity.SHA256 = path, fmt.Sprintf("%x", hash.Sum(nil)) + return identity, nil +} + +func runGoProfiler(ctx context.Context, path string, args []string, streams IOStreams) error { + cmd := exec.CommandContext(ctx, path, args...) + cmd.Stdin, cmd.Stdout, cmd.Stderr = streams.In, streams.Out, streams.ErrOut + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + cmd.Cancel = func() error { return syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM) } + cmd.WaitDelay = 2 * time.Second + err := cmd.Run() + if ctx.Err() != nil { + if cmd.Process != nil { + _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + } + return ctx.Err() + } + var exit *exec.ExitError + if errors.As(err, &exit) { + code := exit.ExitCode() + if status, ok := exit.Sys().(syscall.WaitStatus); ok && status.Signaled() { + code = 128 + int(status.Signal()) + } + return profilerExitError(code) + } + return err +} + +func goProfilerComponents() ([]goProfilerIdentity, error) { + identity, err := findGoProfiler() + if errors.Is(err, errGoProfilerMissing) { + return []goProfilerIdentity{}, nil + } + if err != nil { + return nil, err + } + return []goProfilerIdentity{identity}, nil +} 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 +} From a2eb0db180c6e72c5ede87d02165438fa3c47bff Mon Sep 17 00:00:00 2001 From: Colin Lacy Date: Sat, 7 Nov 2026 08:00:23 +0000 Subject: [PATCH 06/10] imports go profiler --- go.mod | 7 ++ go.sum | 18 ++++ internal/cli/go_profiler.go | 159 ++++++++++++++++++++++++++++++++++++ internal/cli/profile.go | 24 ++++-- scripts/build.go | 80 ++++++++++++++++++ 5 files changed, 280 insertions(+), 8 deletions(-) create mode 100644 internal/cli/go_profiler.go create mode 100644 scripts/build.go diff --git a/go.mod b/go.mod index c883afd..6548fe1 100644 --- a/go.mod +++ b/go.mod @@ -5,11 +5,18 @@ 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 index e63b363..47c964f 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +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..a82a620 --- /dev/null +++ b/internal/cli/go_profiler.go @@ -0,0 +1,159 @@ +package cli + +import ( + "context" + "errors" + "flag" + "fmt" + "os" + "path/filepath" + "runtime/debug" + "strings" + + "github.com/runtimeconditions/go-rc-profiler/extensioncheck" + "github.com/runtimeconditions/go-rc-profiler/extractor" + "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 +} + +// The Go profiler is called as a library. A working Go toolchain is still used +// by that library to inspect the workload; no profiler executable is required. +func runLinkedGoProfiler(ctx context.Context, args []string, streams IOStreams) error { + operation := "generate" + if len(args) > 0 { + switch args[0] { + case "--version", "version": + identity := linkedGoProfilerIdentity() + _, err := fmt.Fprintf(streams.Out, "%s %s (linked into rc)\n", identity.Name, identity.Version) + return err + case "generate", "validate-extension", "validate-extensions": + operation, args = args[0], args[1:] + default: + if !strings.HasPrefix(args[0], "-") { + return usageError{fmt.Errorf("unknown Go profiler command %q", args[0])} + } + } + } + flags := flag.NewFlagSet("rc profile go "+operation, flag.ContinueOnError) + flags.SetOutput(streams.ErrOut) + dir := flags.String("dir", ".", "directory containing Go source declarations") + var name, uri, version, output, packagePath string + if operation == "generate" { + flags.StringVar(&name, "name", "", "profile metadata.name") + flags.StringVar(&uri, "workload-uri", "", "workload.uri") + flags.StringVar(&version, "workload-version", "dev", "workload.version") + flags.StringVar(&output, "out", "", "output file path; defaults to stdout") + } + if operation == "validate-extension" { + flags.StringVar(&packagePath, "package", "", "import path of a generated extension binding package") + } + if err := flags.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return nil + } + return profilerExitError(2) // flag.Parse already printed its diagnostic. + } + if flags.NArg() != 0 { + fmt.Fprintf(streams.ErrOut, "unexpected positional arguments: %s\n", strings.Join(flags.Args(), " ")) + return profilerExitError(2) + } + if err := ctx.Err(); err != nil { + return err + } + directory, err := filepath.Abs(*dir) + if err != nil { + return err + } + if operation != "generate" { + var packages []string + if operation == "validate-extension" { + if packagePath == "" { + return errors.New("-package is required") + } + packages = []string{packagePath} + } + if _, err := extensioncheck.ValidateImportedGoPackages(ctx, directory, packages); err != nil { + return err + } + _, err := fmt.Fprintln(streams.ErrOut, "runtimeconditions: generated binding structures validated") + return err + } + if name == "" { + name = filepath.Base(directory) + } + if uri == "" { + uri = goWorkloadURI(directory) + } + // The current upstream extraction API does not accept a context. Check + // cancellation before and after it without leaving analysis in a goroutine. + profile, err := extractor.ExtractGeneratedDir(directory, extractor.GeneratedOptions{ + Name: name, WorkloadURI: uri, WorkloadVersion: version, + }) + if err != nil { + return err + } + if err := ctx.Err(); err != nil { + return err + } + data, err := extractor.MarshalProfileYAML(profile) + if err != nil { + return err + } + if output != "" { + return writeGoProfileAtomically(output, data) + } + _, err = streams.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/profile.go b/internal/cli/profile.go index 2b19f4c..10b920a 100644 --- a/internal/cli/profile.go +++ b/internal/cli/profile.go @@ -23,12 +23,14 @@ type profilerExitError int func (e profilerExitError) Error() string { return fmt.Sprintf("profiler exited with status %d", e) } type goProfilerIdentity struct { - Name string `yaml:"name"` - Path string `yaml:"path"` - Origin string `yaml:"origin"` - Version string `yaml:"version,omitempty"` - Commit string `yaml:"commit,omitempty"` - SHA256 string `yaml:"sha256"` + 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"` } type goProfilerAsset struct { @@ -53,8 +55,8 @@ var errGoProfilerMissing = errors.New("Go profiler unavailable; install the comp func newProfileCommand() *cobra.Command { return &cobra.Command{ Use: "profile [go] [profiler arguments...]", - Short: "Generate profiles with the Go profiler by default", - Long: "Generate profiles with the bundled Go profiler. Go analysis requires a working Go toolchain.\n" + + 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.\n" + "Arguments after go, or an unqualified Go operation, pass unchanged to the profiler.", Example: " rc profile generate -dir ./service\n rc profile go generate -dir ./service\n rc profile go --help", DisableFlagParsing: true, @@ -89,6 +91,9 @@ func newProfileCommand() *cobra.Command { } func findGoProfiler() (goProfilerIdentity, error) { + if os.Getenv("RC_GO_PROFILER_BIN") == "" { + return linkedGoProfilerIdentity(), nil + } self, err := os.Executable() if err != nil { return goProfilerIdentity{}, err @@ -201,6 +206,9 @@ func identifyGoProfiler(path, self, origin string) (goProfilerIdentity, error) { } func runGoProfiler(ctx context.Context, path string, args []string, streams IOStreams) error { + if path == "" { + return runLinkedGoProfiler(ctx, args, streams) + } cmd := exec.CommandContext(ctx, path, args...) cmd.Stdin, cmd.Stdout, cmd.Stderr = streams.In, streams.Out, streams.ErrOut cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} 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() +} From fa4012c6a1b3c6f468ba81ad9eee530fcb2e1138 Mon Sep 17 00:00:00 2001 From: Colin Lacy Date: Sat, 7 Nov 2026 08:18:45 +0000 Subject: [PATCH 07/10] finishes building the go profiler --- internal/cli/go_profiler.go | 162 ++++++++++++--------- internal/cli/go_profiler_test.go | 220 ++++++++++++++++++++++++++++ internal/cli/profile.go | 240 +++--------------------------- internal/cli/root.go | 4 - internal/cli/root_test.go | 243 ------------------------------- 5 files changed, 336 insertions(+), 533 deletions(-) create mode 100644 internal/cli/go_profiler_test.go diff --git a/internal/cli/go_profiler.go b/internal/cli/go_profiler.go index a82a620..341ceff 100644 --- a/internal/cli/go_profiler.go +++ b/internal/cli/go_profiler.go @@ -3,15 +3,15 @@ package cli import ( "context" "errors" - "flag" "fmt" + "io" "os" "path/filepath" "runtime/debug" - "strings" "github.com/runtimeconditions/go-rc-profiler/extensioncheck" "github.com/runtimeconditions/go-rc-profiler/extractor" + "github.com/spf13/cobra" "golang.org/x/mod/modfile" ) @@ -33,93 +33,125 @@ func linkedGoProfilerIdentity() goProfilerIdentity { return identity } -// The Go profiler is called as a library. A working Go toolchain is still used -// by that library to inspect the workload; no profiler executable is required. -func runLinkedGoProfiler(ctx context.Context, args []string, streams IOStreams) error { - operation := "generate" - if len(args) > 0 { - switch args[0] { - case "--version", "version": - identity := linkedGoProfilerIdentity() - _, err := fmt.Fprintf(streams.Out, "%s %s (linked into rc)\n", identity.Name, identity.Version) +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 - case "generate", "validate-extension", "validate-extensions": - operation, args = args[0], args[1:] - default: - if !strings.HasPrefix(args[0], "-") { - return usageError{fmt.Errorf("unknown Go profiler command %q", args[0])} + }, + } + 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")} } - } - } - flags := flag.NewFlagSet("rc profile go "+operation, flag.ContinueOnError) - flags.SetOutput(streams.ErrOut) - dir := flags.String("dir", ".", "directory containing Go source declarations") - var name, uri, version, output, packagePath string - if operation == "generate" { - flags.StringVar(&name, "name", "", "profile metadata.name") - flags.StringVar(&uri, "workload-uri", "", "workload.uri") - flags.StringVar(&version, "workload-version", "dev", "workload.version") - flags.StringVar(&output, "out", "", "output file path; defaults to stdout") - } - if operation == "validate-extension" { - flags.StringVar(&packagePath, "package", "", "import path of a generated extension binding package") - } - if err := flags.Parse(args); err != nil { - if errors.Is(err, flag.ErrHelp) { return nil } - return profilerExitError(2) // flag.Parse already printed its diagnostic. } - if flags.NArg() != 0 { - fmt.Fprintf(streams.ErrOut, "unexpected positional arguments: %s\n", strings.Join(flags.Args(), " ")) - return profilerExitError(2) + 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(*dir) + directory, err := filepath.Abs(options.Directory) if err != nil { return err } - if operation != "generate" { - var packages []string - if operation == "validate-extension" { - if packagePath == "" { - return errors.New("-package is required") - } - packages = []string{packagePath} - } - if _, err := extensioncheck.ValidateImportedGoPackages(ctx, directory, packages); err != nil { - return err - } - _, err := fmt.Fprintln(streams.ErrOut, "runtimeconditions: generated binding structures validated") - return err - } - if name == "" { - name = filepath.Base(directory) + if options.Identity.Name == "" { + options.Identity.Name = filepath.Base(directory) } - if uri == "" { - uri = goWorkloadURI(directory) + if options.Identity.WorkloadURI == "" { + options.Identity.WorkloadURI = goWorkloadURI(directory) } - // The current upstream extraction API does not accept a context. Check - // cancellation before and after it without leaving analysis in a goroutine. - profile, err := extractor.ExtractGeneratedDir(directory, extractor.GeneratedOptions{ - Name: name, WorkloadURI: uri, WorkloadVersion: version, - }) + // 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 := extractor.MarshalProfileYAML(profile) + data, err := api.Marshal(profile) if err != nil { return err } - if output != "" { - return writeGoProfileAtomically(output, data) + if options.Output != "" { + return writeGoProfileAtomically(options.Output, data) } - _, err = streams.Out.Write(data) + _, err = out.Write(data) return err } 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 index 10b920a..1b3c86f 100644 --- a/internal/cli/profile.go +++ b/internal/cli/profile.go @@ -1,26 +1,6 @@ package cli -import ( - "context" - "crypto/sha256" - "errors" - "fmt" - "io" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" - "syscall" - "time" - - "github.com/spf13/cobra" - "go.yaml.in/yaml/v3" -) - -type profilerExitError int - -func (e profilerExitError) Error() string { return fmt.Sprintf("profiler exited with status %d", e) } +import "github.com/spf13/cobra" type goProfilerIdentity struct { Name string `yaml:"name"` @@ -33,212 +13,30 @@ type goProfilerIdentity struct { ModuleSum string `yaml:"moduleSum,omitempty"` } -type goProfilerAsset struct { - URL string `yaml:"url"` - ArchiveSHA256 string `yaml:"archiveSHA256"` - ExecutableSHA256 string `yaml:"executableSHA256"` -} - -type goProfilerLock 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]goProfilerAsset `yaml:"platforms"` - } `yaml:"goProfiler"` -} - -var errGoProfilerMissing = errors.New("Go profiler unavailable; install the complete rc-cli bundle or set RC_GO_PROFILER_BIN to an absolute executable path") - func newProfileCommand() *cobra.Command { - return &cobra.Command{ - Use: "profile [go] [profiler arguments...]", - 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.\n" + - "Arguments after go, or an unqualified Go operation, pass unchanged to the profiler.", - Example: " rc profile generate -dir ./service\n rc profile go generate -dir ./service\n rc profile go --help", - DisableFlagParsing: true, - Args: cobra.ArbitraryArgs, - ValidArgsFunction: func(_ *cobra.Command, args []string, prefix string) ([]string, cobra.ShellCompDirective) { - if len(args) == 0 && strings.HasPrefix("go", prefix) { - return []string{"go"}, cobra.ShellCompDirectiveNoFileComp - } - return nil, cobra.ShellCompDirectiveNoFileComp - }, - RunE: func(cmd *cobra.Command, args []string) error { - if len(args) == 0 || (len(args) == 1 && (args[0] == "--help" || args[0] == "-h")) { - return cmd.Help() - } - switch args[0] { - case "go": - args = args[1:] - case "generate", "validate-extension", "validate-extensions", "version": - default: - if !strings.HasPrefix(args[0], "-") { - return usageError{fmt.Errorf("unsupported profiler language %q; Go is included by default", args[0])} - } - } - profiler, err := findGoProfiler() - if err != nil { - return err - } - return runGoProfiler(cmd.Context(), profiler.Path, args, - IOStreams{In: cmd.InOrStdin(), Out: cmd.OutOrStdout(), ErrOut: cmd.ErrOrStderr()}) - }, - } -} - -func findGoProfiler() (goProfilerIdentity, error) { - if os.Getenv("RC_GO_PROFILER_BIN") == "" { - return linkedGoProfilerIdentity(), nil - } - self, err := os.Executable() - if err != nil { - return goProfilerIdentity{}, err - } - return locateGoProfiler(self, os.Getenv("RC_GO_PROFILER_BIN")) -} - -func locateGoProfiler(self, override string) (goProfilerIdentity, error) { - self, err := filepath.EvalSymlinks(self) - if err != nil { - return goProfilerIdentity{}, err - } - if override != "" { - if !filepath.IsAbs(override) { - return goProfilerIdentity{}, errors.New("RC_GO_PROFILER_BIN must be an absolute path") - } - return identifyGoProfiler(override, self, "override") - } - prefix := filepath.Dir(filepath.Dir(self)) - paths := []string{filepath.Join(prefix, "libexec", "rc-cli", "go-rc-profiler"), filepath.Join(prefix, "lib", "rc-cli", "go-rc-profiler")} - manifest := filepath.Join(prefix, "share", "rc-cli", "components.lock.yaml") - file, err := os.Open(manifest) - if err == nil { - defer file.Close() - decoder := yaml.NewDecoder(io.LimitReader(file, 64*1024+1)) - decoder.KnownFields(true) - var lock goProfilerLock - if stat, statErr := file.Stat(); statErr != nil || stat.Size() > 64*1024 { - return goProfilerIdentity{}, errors.New("invalid or oversized component lock") - } - if err = decoder.Decode(&lock); err != nil { - return goProfilerIdentity{}, fmt.Errorf("component lock: %w", 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 goProfilerIdentity{}, errors.New("unsupported or incomplete component lock") - } - asset, ok := lock.GoProfiler.Platforms[runtime.GOOS+"/"+runtime.GOARCH] - if !ok { - return goProfilerIdentity{}, errors.New("component lock does not support this platform") - } - for _, path := range paths { - if _, err := os.Lstat(path); errors.Is(err, os.ErrNotExist) { - continue - } - identity, err := identifyGoProfiler(path, self, "bundled") - if err != nil { - return identity, err - } - if identity.SHA256 != asset.ExecutableSHA256 { - return identity, errors.New("bundled Go profiler checksum mismatch; reinstall rc-cli") - } - identity.Version, identity.Commit = lock.GoProfiler.Version, lock.GoProfiler.Commit - return identity, nil - } - return goProfilerIdentity{}, errors.New("component lock exists but bundled Go profiler is missing") - } - if !errors.Is(err, os.ErrNotExist) { - return goProfilerIdentity{}, err - } - for _, path := range paths { - if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) { - return goProfilerIdentity{}, errors.New("bundled Go profiler has no component lock") - } - } - path, err := exec.LookPath("go-rc-profiler") - if err != nil { - return goProfilerIdentity{}, errGoProfilerMissing - } - return identifyGoProfiler(path, self, "path") + return newGoProfileCommands(linkedGoProfilerAPI()) } -func identifyGoProfiler(path, self, origin string) (goProfilerIdentity, error) { - identity := goProfilerIdentity{Name: "go-rc-profiler", Origin: origin} - path, err := filepath.EvalSymlinks(path) - if err != nil { - return identity, err - } - path, err = filepath.Abs(path) - if err != nil { - return identity, err - } - if origin == "bundled" { - relative, err := filepath.Rel(filepath.Dir(filepath.Dir(self)), path) - if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(os.PathSeparator)) { - return identity, errors.New("bundled Go profiler escapes the installation prefix") - } - } - stat, err := os.Stat(path) - if err != nil { - return identity, err - } - if !stat.Mode().IsRegular() || stat.Mode().Perm()&0111 == 0 { - return identity, errors.New("Go profiler must be a regular executable file") - } - if current, err := os.Stat(self); err != nil || os.SameFile(stat, current) { - return identity, errors.New("Go profiler cannot resolve to the rc executable") +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", } - file, err := os.Open(path) - if err != nil { - return identity, err + 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()) } - defer file.Close() - hash := sha256.New() - if _, err := io.Copy(hash, file); err != nil { - return identity, err - } - identity.Path, identity.SHA256 = path, fmt.Sprintf("%x", hash.Sum(nil)) - return identity, nil -} - -func runGoProfiler(ctx context.Context, path string, args []string, streams IOStreams) error { - if path == "" { - return runLinkedGoProfiler(ctx, args, streams) - } - cmd := exec.CommandContext(ctx, path, args...) - cmd.Stdin, cmd.Stdout, cmd.Stderr = streams.In, streams.Out, streams.ErrOut - cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} - cmd.Cancel = func() error { return syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM) } - cmd.WaitDelay = 2 * time.Second - err := cmd.Run() - if ctx.Err() != nil { - if cmd.Process != nil { - _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) - } - return ctx.Err() - } - var exit *exec.ExitError - if errors.As(err, &exit) { - code := exit.ExitCode() - if status, ok := exit.Sys().(syscall.WaitStatus); ok && status.Signaled() { - code = 128 + int(status.Signal()) - } - return profilerExitError(code) - } - return err + profile.AddCommand(goCommand) + return profile } func goProfilerComponents() ([]goProfilerIdentity, error) { - identity, err := findGoProfiler() - if errors.Is(err, errGoProfilerMissing) { - return []goProfilerIdentity{}, nil - } - if err != nil { - return nil, err - } - return []goProfilerIdentity{identity}, nil + return []goProfilerIdentity{linkedGoProfilerIdentity()}, nil } diff --git a/internal/cli/root.go b/internal/cli/root.go index fb96dd4..7fc2570 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -55,10 +55,6 @@ func Execute(ctx context.Context, args []string, build BuildInfo, streams IOStre if err == nil { return 0 } - var backendExit profilerExitError - if errors.As(err, &backendExit) { - return int(backendExit) - } fmt.Fprintf(streams.ErrOut, "rc: %v\n", err) if errors.Is(err, context.Canceled) { return 130 diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index e66dd82..8b268f3 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -3,21 +3,11 @@ package cli import ( "bytes" "context" - "crypto/sha256" - "encoding/json" "errors" - "fmt" "io" - "os" - "os/exec" - "path/filepath" - "reflect" "runtime" - "strconv" "strings" - "syscall" "testing" - "time" "github.com/spf13/cobra" "go.yaml.in/yaml/v3" @@ -162,236 +152,3 @@ func TestCommandIsolationAndInjectedStreams(t *testing.T) { t.Fatalf("nil stream defaults: exit %d", code) } } - -func TestMain(m *testing.M) { - if os.Getenv("RC_CLI_TEST_HELPER") == "1" { - switch os.Getenv("RC_CLI_TEST_HELPER_MODE") { - case "exit": - fmt.Fprintln(os.Stderr, "backend diagnostic") - os.Exit(23) - case "signal": - _ = syscall.Kill(os.Getpid(), syscall.SIGTERM) - case "wait": - child := exec.Command(os.Args[0]) - child.Env = append(os.Environ(), "RC_CLI_TEST_HELPER_MODE=sleep") - if err := child.Start(); err != nil { - panic(err) - } - if err := os.WriteFile(os.Getenv("RC_CLI_TEST_READY"), []byte(strconv.Itoa(child.Process.Pid)), 0600); err != nil { - panic(err) - } - time.Sleep(30 * time.Second) - case "sleep": - time.Sleep(30 * time.Second) - default: - input, _ := io.ReadAll(os.Stdin) - cwd, _ := os.Getwd() - _ = json.NewEncoder(os.Stdout).Encode(map[string]any{ - "args": os.Args[1:], "input": string(input), "cwd": cwd, "value": os.Getenv("RC_CLI_TEST_VALUE"), - }) - } - os.Exit(0) - } - os.Exit(m.Run()) -} - -func fakeGoProfiler(t *testing.T) string { - t.Helper() - self, err := os.Executable() - if err != nil { - t.Fatal(err) - } - binary, err := os.ReadFile(self) - if err != nil { - t.Fatal(err) - } - path := filepath.Join(t.TempDir(), "go profiler") - if err := os.WriteFile(path, binary, 0755); err != nil { - t.Fatal(err) - } - t.Setenv("RC_GO_PROFILER_BIN", path) - t.Setenv("RC_CLI_TEST_HELPER", "1") - t.Setenv("RC_CLI_TEST_HELPER_MODE", "args") - t.Setenv("GORACE", "atexit_sleep_ms=0") - return path -} - -func TestProfileArgumentAndStreamPreservation(t *testing.T) { - fakeGoProfiler(t) - t.Setenv("RC_CLI_TEST_VALUE", "inherited") - cwd, _ := os.Getwd() - cases := [][]string{ - {"generate", "-dir", "path with spaces", "--name", "first", "--name", "second", "--", "tail"}, - {"go", "generate", "-dir", "path with spaces", "--name", "first", "--name", "second", "--", "tail"}, - {"go", "--help"}, {"go", "--version"}, {"--version"}, {"version"}, - {"validate-extension", "-package", "example/binding"}, {"validate-extensions"}, {"go"}, - } - for _, args := range cases { - var out, errOut bytes.Buffer - code := Execute(context.Background(), append([]string{"profile"}, args...), BuildInfo{}, - IOStreams{In: strings.NewReader("input bytes"), Out: &out, ErrOut: &errOut}) - var got struct { - Args []string - Input, Cwd, Value string - } - if err := json.Unmarshal(out.Bytes(), &got); err != nil { - t.Fatalf("%v: %v; %q", args, err, out.String()) - } - want := args - if args[0] == "go" { - want = args[1:] - } - if code != 0 || errOut.Len() != 0 || !reflect.DeepEqual(got.Args, want) || - got.Input != "input bytes" || got.Cwd != cwd || got.Value != "inherited" { - t.Fatalf("%v: code=%d got=%+v stderr=%q", args, code, got, errOut.String()) - } - } -} - -func TestProfileHostHelpAndUnknownLanguage(t *testing.T) { - t.Setenv("RC_GO_PROFILER_BIN", "/missing/profiler") - for _, args := range [][]string{{"profile"}, {"profile", "--help"}, {"profile", "-h"}, {"help", "profile"}} { - code, out, errOut := invoke(t, args...) - if code != 0 || !strings.Contains(out, "rc profile go") || errOut != "" { - t.Fatalf("%v: code=%d stdout=%q stderr=%q", args, code, out, errOut) - } - } - code, out, errOut := invoke(t, "profile", "java", "generate") - if code != 2 || out != "" || !strings.Contains(errOut, "unsupported profiler language") { - t.Fatalf("code=%d stdout=%q stderr=%q", code, out, errOut) - } - code, out, _ = invoke(t, "__complete", "profile", "") - if code != 0 || !strings.Contains(out, "go\n") { - t.Fatalf("code=%d stdout=%q", code, out) - } -} - -func TestProfileExitStatus(t *testing.T) { - fakeGoProfiler(t) - t.Setenv("RC_CLI_TEST_HELPER_MODE", "exit") - code, out, errOut := invoke(t, "profile", "go", "--unknown") - if code != 23 || out != "" || errOut != "backend diagnostic\n" { - t.Fatalf("code=%d stdout=%q stderr=%q", code, out, errOut) - } - t.Setenv("RC_CLI_TEST_HELPER_MODE", "signal") - code, _, _ = invoke(t, "profile", "version") - if code != 143 { - t.Fatalf("signaled backend: exit=%d", code) - } -} - -func TestProfileCancellationStopsDescendants(t *testing.T) { - fakeGoProfiler(t) - t.Setenv("RC_CLI_TEST_HELPER_MODE", "wait") - ready := filepath.Join(t.TempDir(), "child.pid") - t.Setenv("RC_CLI_TEST_READY", ready) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - done := make(chan int, 1) - go func() { done <- Execute(ctx, []string{"profile", "generate"}, BuildInfo{}, IOStreams{}) }() - var childPID int - deadline := time.Now().Add(5 * time.Second) - for childPID == 0 && time.Now().Before(deadline) { - if data, err := os.ReadFile(ready); err == nil { - childPID, _ = strconv.Atoi(string(data)) - } - time.Sleep(10 * time.Millisecond) - } - cancel() - select { - case code := <-done: - if code != 130 { - t.Fatalf("canceled profiler: exit=%d", code) - } - case <-time.After(4 * time.Second): - t.Fatal("profiler cancellation timed out") - } - if childPID == 0 { - t.Fatal("profiler did not start its child") - } - defer syscall.Kill(childPID, syscall.SIGKILL) - for i := 0; i < 100; i++ { - if err := syscall.Kill(childPID, 0); errors.Is(err, syscall.ESRCH) { - return - } - time.Sleep(10 * time.Millisecond) - } - t.Fatal("profiler descendant survived cancellation") -} - -func TestGoProfilerOverrideAndVersionIdentity(t *testing.T) { - path := fakeGoProfiler(t) - path, _ = filepath.EvalSymlinks(path) - code, out, errOut := invoke(t, "version", "--output", "yaml") - var doc struct { - Components []goProfilerIdentity `yaml:"components"` - } - if err := yaml.Unmarshal([]byte(out), &doc); err != nil { - t.Fatal(err) - } - if code != 0 || errOut != "" || len(doc.Components) != 1 || - doc.Components[0].Origin != "override" || doc.Components[0].Path != path || len(doc.Components[0].SHA256) != 64 { - t.Fatalf("code=%d document=%+v stderr=%q", code, doc, errOut) - } - t.Setenv("RC_GO_PROFILER_BIN", "relative") - if code, _, _ := invoke(t, "profile", "generate"); code != 1 { - t.Fatalf("relative override: exit=%d", code) - } - self, _ := os.Executable() - t.Setenv("RC_GO_PROFILER_BIN", self) - if code, _, errOut := invoke(t, "profile", "generate"); code != 1 || !strings.Contains(errOut, "cannot resolve to the rc executable") { - t.Fatalf("recursive override: code=%d stderr=%q", code, errOut) - } -} - -func TestBundledGoProfilerDiscoveryAndIntegrity(t *testing.T) { - for _, layout := range []string{"libexec", "lib"} { - t.Run(layout, func(t *testing.T) { - prefix := t.TempDir() - self := filepath.Join(prefix, "bin", "rc") - binary := filepath.Join(prefix, layout, "rc-cli", "go-rc-profiler") - manifest := filepath.Join(prefix, "share", "rc-cli", "components.lock.yaml") - for _, path := range []string{self, binary, manifest} { - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - t.Fatal(err) - } - } - if err := os.WriteFile(self, []byte("cli"), 0755); err != nil { - t.Fatal(err) - } - data := []byte("profiler") - if err := os.WriteFile(binary, data, 0755); err != nil { - t.Fatal(err) - } - lockBytes, err := os.ReadFile("../../distribution/components.lock.yaml") - if err != nil { - t.Fatal(err) - } - var lock goProfilerLock - if err := yaml.Unmarshal(lockBytes, &lock); err != nil { - t.Fatal(err) - } - key := runtime.GOOS + "/" + runtime.GOARCH - asset := lock.GoProfiler.Platforms[key] - asset.ExecutableSHA256 = fmt.Sprintf("%x", sha256.Sum256(data)) - lock.GoProfiler.Platforms[key] = asset - lockBytes, err = yaml.Marshal(lock) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(manifest, lockBytes, 0644); err != nil { - t.Fatal(err) - } - found, err := locateGoProfiler(self, "") - if err != nil || found.Origin != "bundled" || found.Version != lock.GoProfiler.Version { - t.Fatalf("identity=%+v err=%v", found, err) - } - if err := os.WriteFile(binary, []byte("tampered"), 0755); err != nil { - t.Fatal(err) - } - if _, err := locateGoProfiler(self, ""); err == nil || !strings.Contains(err.Error(), "checksum mismatch") { - t.Fatalf("tampered bundle accepted: %v", err) - } - }) - } -} From c9f9417f2249034bbcf8a452e2967025b887ad0d Mon Sep 17 00:00:00 2001 From: Colin Lacy Date: Sat, 7 Nov 2026 08:35:32 +0000 Subject: [PATCH 08/10] adds ci for build --- .github/workflows/ci.yml | 101 +++++++++++++ scripts/ci-smoke.go | 250 +++++++++++++++++++++++++++++++++ testdata/ci/go-project/go.mod | 5 + testdata/ci/go-project/go.sum | 2 + testdata/ci/go-project/main.go | 14 ++ 5 files changed, 372 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 scripts/ci-smoke.go create mode 100644 testdata/ci/go-project/go.mod create mode 100644 testdata/ci/go-project/go.sum create mode 100644 testdata/ci/go-project/main.go 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/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/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() {} From 673c46e437836b1b94610b912827901df7c07564 Mon Sep 17 00:00:00 2001 From: Colin Lacy Date: Sat, 7 Nov 2026 09:35:07 +0000 Subject: [PATCH 09/10] remvoes agents file --- AGENTS.md | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 703a2a0..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,9 +0,0 @@ -DO NOT CREATE OR EDIT MORE THAN 5 FILES AT A TIME WITHOUT CLEAR APPROVAL. IF YOU CANNOT TELL ME, AHEAD OF TIME, WHICH 5 FILES YOU ARE GOING TO CREATE OR EDIT, YOU ARE DOING IT WRONG. YOU ARE SUPPOSED TO BE THE LATEST FRONTIER MODEL, CAPABLE OF REASONING AT INSANE SPEEDS WITH MASSIVE CONTEXT LENGTH. YOU NEED TO PLAN AHEAD, AND BE CLEAR ABOUT WHAT YOU'RE GOING TO DO. - -NO FILE SHOULD HAVE MORE THAN 250 LINES OF EDITS DURING A TURN. THAT MEANS THAT, AT MOST, IN A COMBINED 5 FILES, YOU MAY NOT EDIT MORE THAN 1250 LINES. - -At the end of each turn, you need to have created COMPLETE functions and objects. DO NOT stop in the middle of a block of code. It's ok, if the software as a whole isn't working YET, but you need to indicate to me what you've done so far, and what your next move is. - -All of your output MUST be concise and to the point. You MUST NOT use any ambiguous words or introduce new concepts to the discussion without explaining them. - -Each turn MUST end with an explanation of what your next move will be, and indicate to me what input you will need before you can proceed. \ No newline at end of file From 4a1c44d8105cb97e58b53f46fe5b42fdadb99023 Mon Sep 17 00:00:00 2001 From: Colin Lacy Date: Sat, 7 Nov 2026 09:35:39 +0000 Subject: [PATCH 10/10] adds gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .gitignore 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