Repository navigation
Conversation
…r and looking up command registry (v2 legacy)
…mand calls and cleanup warning outputs
|
would like to mention that the CVE check failures applies to the current live branch, when those are updated a fork sync will also solve it. |
There was a problem hiding this comment.
🤖 AI-Assisted Architectural & Compatibility Review
Thank you for putting together this proof of concept! Migrating the CLI plugin RPC layer from Cloud Controller V2 to V3 is a critical and long-awaited step (#3621).
We conducted an adversarial review and empirical test validation of the proposed changes against origin/v8. While the overall direction of using v7action.Actor and configv3.Config is sound, we identified two blocking launch/lifecycle regressions and several public model mapping discrepancies that would break existing plugins relying on backward compatibility:
Key Findings Summary
- [Blocker] Offline Plugin Launch (F1):
PluginRunner.Rununconditionally dials Cloud Controller before starting the RPC listener, failing withNo API endpoint setwhen no target is configured (plugin/runner/runner.go:79). - [Blocker] Lifecycle Service Nil Dereference (F2): Lifecycle commands (
uninstall-plugin) createRpcServicewithnildependencies; plugin uninstall callbacks callingAccessToken()panic on nil dereference (command/plugin/shared/rpc.go:46). - [Should-Fix] Public Model Mapping Regressions:
UserEmail()returns username rather than the JWT email claim (plugin/rpc/cli_rpc_server.go:255).GetSpaceduplicates security groups bound to both running and staging (plugin/rpc/cli_rpc_server.go:618).- Application
Statecasing changes from"started"to"STARTED"(plugin/rpc/mappings.go:381). - Role strings change from legacy constants (
RoleOrgManager) to v3 enums (organization_manager) (plugin/rpc/mappings.go:118). - Unlimited quota limits become
0instead of-1due to null unmarshaling (plugin/rpc/mappings.go:189). Domain.Namereceives full route URLs rather than domain names (plugin/rpc/mappings.go:462).GetAppreturns binding names instead of service instance names (plugin/rpc/mappings.go:471).GetServicesdropsServicePlan.Guid(plugin/rpc/mappings.go:84).GetAppsreports observed live instances instead of desired scale for stopped apps (plugin/rpc/mappings.go:503).GetAppexposes redacted start command[PRIVATE DATA HIDDEN IN LISTS]and omits environment variables (plugin/rpc/cli_rpc_server.go:331).
- [Should-Fix] Concurrency Data Race (F4):
CallCoreCommandshares an unsynchronizedbytes.Bufferacross stdout and stderr interceptors (plugin/rpc/cli_rpc_server.go:53). - [Should-Fix] Credential Persistence (F19): Token refreshed during RPC
AccessToken()is not saved to disk on exit, risking UAA refresh token invalidation (plugin/rpc/cli_rpc_server.go:315).
Detailed inline review comments with citations to current origin/v8 source code and suggested fixes are attached below.
| sharedActor := sharedaction.NewActor(r.config) | ||
|
|
||
| // Get clients and connect to CF | ||
| ccClient, uaaClient, routingClient, err := shared.GetNewClientsAndConnectToCF(r.config, r.commandUI, "") |
There was a problem hiding this comment.
🤖 AI Review [Blocker]: Plugin launch fails when no API target is configured
- Issue:
PluginRunner.Runcallsshared.GetNewClientsAndConnectToCFunconditionally before launching the plugin child process. If no CF target is configured inconfig.Target(),connectToCFreturnstranslatableerror.NoAPISetError, aborting execution with"error connecting to CF: No API endpoint set"before the plugin starts. - Equivalent behavior in current plugin interface: In
origin/v8(cf/cmd/cmd.go:142andplugin/rpc/run_plugin.go:10), the CLI starts the RPC listener and launches the plugin binary without connecting over the network. This allows offline plugins, target switchers, and plugins inspectingcliConnection.HasAPIEndpoint()to function. - Suggested fix: Only attempt connecting to Cloud Foundry when
r.config.Target() != "". If untargeted, launch the plugin withactor = nil:
var actor *v7action.Actor
if r.commandUI != nil && r.config.Target() != "" {
// initialize sharedActor, clients, and actor
}There was a problem hiding this comment.
F1/F2: Fixed. The runner only builds the actor when an API is targeted, so plugins launch without a target. RPC calls that need Cloud Controller then return a "No API endpoint set" error, while config-only calls (HasAPIEndpoint(), …) keep working. Contexts without an actor or parser (the install/uninstall lifecycle and the legacy paths) return explicit errors instead of panicking. AccessToken() without an actor returns the token stored in the config, without refreshing it. This is a deliberate policy: a plugin's CLI-MESSAGE-UNINSTALL hook is meant for cleaning up its own files and notifying outside services, not for using Cloud Controller.
(4eec7efcd)
| traceLogger := trace.NewLogger(writer, verbose, tracePaths...) | ||
|
|
||
| // Create dependencies needed for RPC service | ||
| deps := commandregistry.NewDependency(writer, traceLogger, os.Getenv("CF_DIAL_TIMEOUT")) |
There was a problem hiding this comment.
🤖 AI Review [Cleanup]: Unused legacy commandregistry.NewDependency initialization
- Issue:
depsis allocated and deferred-closed (deps.Config.Close()), but it is never passed torpc.NewRpcServiceor referenced anywhere inPluginRunner.Run. - Equivalent behavior in current plugin interface: In
origin/v8(cf/cmd/cmd.go:142),depswas actively passed torpc.NewRpcServiceand legacy command runners. - Suggested fix: Remove lines 68–71 in
plugin/runner/runner.goand drop"code.cloudfoundry.org/cli/v8/cf/commandregistry"from imports to avoid unnecessary disk I/O and legacy package coupling.
There was a problem hiding this comment.
Removed. The same unused deps/trace logger was also in command/plugin/shared/rpc.go, and the unused dialTimeout var was in cli_rpc_server.go. Both are gone too, and the new plugin paths no longer import cf/commandregistry.
(a1b335d3e)
|
|
||
| server := netrpc.NewServer() | ||
| rpcService, err := rpc.NewRpcService(deps.TeePrinter, deps.TeePrinter, deps.Config, deps.RepoLocator, rpc.NewCommandRunner(), deps.Logger, ui.Writer(), server) | ||
| rpcService, err := rpc.NewRpcService(v3Config, server, nil, nil, nil) |
There was a problem hiding this comment.
🤖 AI Review [Blocker]: Lifecycle RPC service passes nil dependencies, causing panics
- Issue:
NewRpcServiceis initialized withnilforactor,commandParser, andcommandUI. Duringcf uninstall-plugin, the CLI executes the plugin binary withCLI-MESSAGE-UNINSTALL. If the plugin cleanup routine callsAccessToken(),CallCoreCommand(), orGetApp(), the RPC server dereferencescmd.actorwithout a nil check and crashes with a nil pointer dereference panic. - Equivalent behavior in current plugin interface: In
origin/v8(command/plugin/shared/rpc.go:40andcf/commands/plugin/uninstall_plugin.go:58), the RPC service was supplied with concrete dependencies (deps.RepoLocator,deps.Config,rpc.NewCommandRunner()). - Suggested fix: Add defensive nil guards to all
CliRpcCmdmethods inplugin/rpc/cli_rpc_server.go:
if cmd.actor == nil {
return errors.New("Cloud Controller client not initialized")
}There was a problem hiding this comment.
F1/F2: Fixed. The runner only builds the actor when an API is targeted, so plugins launch without a target. RPC calls that need Cloud Controller then return a "No API endpoint set" error, while config-only calls (HasAPIEndpoint(), …) keep working. Contexts without an actor or parser (the install/uninstall lifecycle and the legacy paths) return explicit errors instead of panicking. AccessToken() without an actor returns the token stored in the config, without refreshing it. This is a deliberate policy: a plugin's CLI-MESSAGE-UNINSTALL hook is meant for cleaning up its own files and notifying outside services, not for using Cloud Controller.
(4eec7efcd)
| type OutputCapture interface { | ||
| SetOutputBucket(io.Writer) | ||
| func (oi *OutputInterceptor) Write(p []byte) (n int, err error) { | ||
| n, err = oi.capturedOutput.Write(p) |
There was a problem hiding this comment.
🤖 AI Review [Should-Fix]: Data race in OutputInterceptor.Write during core command execution
- Issue:
CallCoreCommandinstantiates a singlecmd.outputBucket = &bytes.Buffer{}shared betweenstdoutandstderrOutputInterceptors without mutex synchronization. Core v7 commands that emit to stdout and stderr concurrently from separate goroutines (e.g. streaming logs while logging status warnings) trigger data races onbytes.Buffer.Write, leading to slice index panics or buffer corruption. - Equivalent behavior in current plugin interface: In
origin/v8(cf/terminal/tee_printer.go:61), commands were legacy V2 commands where output was routed throughterminal.TeePrinter. - Suggested fix: Wrap buffer writes in a thread-safe synchronized buffer:
type synchronizedBuffer struct {
mu sync.Mutex
buf bytes.Buffer
}
func (sb *synchronizedBuffer) Write(p []byte) (int, error) {
sb.mu.Lock()
defer sb.mu.Unlock()
return sb.buf.Write(p)
}There was a problem hiding this comment.
Fixed. Captured output now goes through a mutex-guarded buffer shared by the stdout and stderr interceptors. CallCoreCommand/DisableTerminalOutput/GetOutputAndReset are also serialized, because net/rpc serves each plugin connection on its own goroutine and core commands mutate the shared config. Added a -race test that writes to both streams concurrently.
Note: legacy TeePrinter wasn't synchronized either (it used a single stdout stream, so concurrent writes were rarer). With v7 commands writing to both stdout and stderr from several goroutines it became easy to hit, so the buffer is now mutex-protected.
Also note that GetOutputAndReset is a misnomer: since 2015 it hasn't reset anything upstream. It returns the latest core command's output, which is only cleared when the next core command starts. The behavior (and the name, which plugins call by string) is kept to match upstream. As upstream does, the buffer is now created with the RPC service and cleared at the start of each CallCoreCommand.
(3a1685937)
| return nil | ||
| } | ||
| *retVal = user.Name // For UAA users, Name contains the email | ||
|
|
There was a problem hiding this comment.
🤖 AI Review [Should-Fix]: UserEmail returns username claim instead of user email
- Issue:
cmd.cliConfig.CurrentUser()only decodesuser_nameintouser.Name. In enterprise UAA, LDAP, or SAML setups,user_nameis an account handle (e.g.jdoe), while the email address is stored in the"email"claim (e.g.jdoe@example.com). Returninguser.Namepasses the username rather than the email. - Equivalent behavior in current plugin interface: In
origin/v8(cf/configuration/coreconfig/config_repository.go:294andcf/configuration/coreconfig/access_token.go:16),UserEmail()decoded the JWT token and returned the"email"claim fromTokenInfo.Email. - Suggested fix: Decode the
"email"claim directly from the JWT access token usingutiljwt.ParseUnverified:
claims, err := utiljwt.ParseUnverified(cmd.cliConfig.AccessToken())
if err == nil {
if email, ok := claims["email"].(string); ok {
*retVal = email
return nil
}
}
*retVal = ""
return nilThere was a problem hiding this comment.
Fixed. configv3.User now carries the email claim (decoded through utiljwt.ParseUnverified in decodeUserFromJWT, after syncing with upstream), and UserEmail() returns it. The test fixture now uses different values for username and email, so this can't regress silently.
Note: this matches upstream, which decoded the email claim from the stored access token (coreconfig.NewTokenInfo).
(e2ed492d3)
| Domain: plugin_models.GetApp_DomainFields{ | ||
| Guid: route.DomainGUID, | ||
| Name: route.URL, // URL contains the full domain | ||
| }, |
There was a problem hiding this comment.
🤖 AI Review [Should-Fix]: Route.Domain.Name mapped to full route URL instead of domain name
- Issue:
populateAppModel(line 462) andpopulateAppsModel(line 519) setDomain.Name = route.URL.route.URLin CAPI v3 contains the host and domain (e.g.myapp.apps.example.com). Plugins that construct route URLs usingfmt.Sprintf("%s.%s", route.Host, route.Domain.Name)producemyapp.myapp.apps.example.com. - Equivalent behavior in current plugin interface: In
origin/v8(cf/commands/application/app.go:255),Domain.Namewas the actual domain name (e.g.apps.example.com) andHostwasmyapp. - Suggested fix: Extract the domain name by trimming the host prefix from
route.URL:
domainName := route.URL
if route.Host != "" {
domainName = strings.TrimPrefix(domainName, route.Host+".")
}And assign domainName to Domain.Name.
There was a problem hiding this comment.
Fixed. Trimming only the host prefix would still leave paths (/api) and TCP ports (:1024) on the domain, so I exported the existing v7action helper that cf routes already uses (DomainNameFromRouteURL: host prefix, path suffix, port suffix) and used it for GetApp and GetApps. Tests cover HTTP, path, and TCP routes.
(52a4f5039)
| for i, binding := range serviceBindings { | ||
| model.Services[i] = plugin_models.GetApp_ServiceSummary{ | ||
| Guid: binding.ServiceInstanceGUID, | ||
| Name: binding.Name, |
There was a problem hiding this comment.
🤖 AI Review [Should-Fix]: GetApp returns credential binding name instead of service instance name
- Issue:
populateAppModelmapsbinding.NametoServiceSummary.Name.binding.Nameis the name of the service credential binding, not the service instance. For an instance namedmy-dbwith a bindingdb-reader(or an unnamed binding),Namebecomes"db-reader"or"". - Equivalent behavior in current plugin interface: In
origin/v8(cf/commands/application/app.go:270),ServiceSummary.Namecontained the service instance name (service.Name). - Suggested fix: Query service instance details or include service instance names when querying credential bindings, and map the service instance name to
ServiceSummary.Name.
There was a problem hiding this comment.
Fixed. GetApp now resolves the bound instances with a single /v3/service_instances?guids=… call and maps Name to the service instance name. Results are de-duplicated per instance, because v3 allows several bindings to the same instance.
(2a1dc13e1)
|
|
||
| // Populate instance and resource info from web process | ||
| if webProcess != nil { | ||
| model.TotalInstances = webProcess.TotalInstanceCount() |
There was a problem hiding this comment.
🤖 AI Review [Should-Fix]: GetApps reports observed rather than desired instance count
- Issue:
webProcess.TotalInstanceCount()counts entries inInstanceDetails. For a stopped app configured for 3 instances,InstanceDetailsis empty, soTotalInstancesreturns0. - Equivalent behavior in current plugin interface: In
origin/v8(cf/commands/application/apps.go:131),TotalInstancesreported the configured desired instance count (app.InstanceCount), independently of live running instances. - Suggested fix: Populate
TotalInstancesfrom the web process configured scale (webProcess.Instances.Value), matching whatpopulateAppModelalready does forGetApp:
if webProcess != nil {
model.TotalInstances = webProcess.Instances.Value
model.RunningInstances = webProcess.HealthyInstanceCount()
}There was a problem hiding this comment.
Fixed. TotalInstances now comes from the web process's desired instances (the same source GetApp uses for InstanceCount), so a stopped app scaled to 3 reports 3/0. Apps without a web process now fall back to their first process (in GetApp too) instead of reporting zeros.
(3179ffa1e)
| // Build config options | ||
| userAgent := fmt.Sprintf("cf-plugin/%s", version.VersionString()) | ||
| opts := []cfconfig.Option{ | ||
| cfconfig.Token(token, ""), |
There was a problem hiding this comment.
🤖 AI Review [Note]: CfClient() cannot refresh expired tokens during long-running plugin operations
- Issue:
cfconfig.Token(token, "")supplies an empty refresh token. Plugins executing long tasks (such as database migrations, bulk exports, or deployment monitoring) will fail withoauth2: token expiredonce the access token expires. - Equivalent behavior in current plugin interface: In
origin/v8(plugin/cli_connection.go:278), plugins making direct API calls repeatedly invokedcliConnection.AccessToken(), which auto-refreshed expired tokens via the RPC server. - Suggested fix: Add a
RefreshTokenRPC method onCliRpcCmdsocliConnection.CfClient()can obtain both tokens:
opts := []cfconfig.Option{
cfconfig.Token(token, refreshToken),
cfconfig.UserAgent(userAgent),
}Alternatively, document in godoc that CfClient() is scoped to short-lived operations.
There was a problem hiding this comment.
Documented on CliConnection.CfClient(): the returned client is good until the current access token expires, and long-running plugins should call CfClient() again (each call goes through the AccessToken RPC, which refreshes when needed). I chose not to expose the refresh token: the legacy plugin API never did, and letting go-cfclient refresh on its own could rotate the UAA refresh token behind the CLI's back and log the user out (the F19 problem). Adding token refresh to CfClient() (a RefreshToken RPC, or preferably an RPC-backed oauth2.TokenSource, which needs a go-cfclient option that doesn't exist yet) is left for a future improvement PR rather than this one.
(692925078)
| // is only returned when ParseCommandFromArgs receives at least one argument. | ||
| // The command parser requires a command name to generate this error. | ||
| if len(os.Args) > 1 { | ||
| plugin, commandIsPlugin = config.FindPluginByCommand(os.Args[1]) |
There was a problem hiding this comment.
🤖 AI Review [Pre-existing]: Global flags preceding a plugin command prevent plugin execution
- Issue: Checking
os.Args[1]means commands likecf -v <plugin-command>evaluateFindPluginByCommand("-v"), which returns false and falls through to the unknown command handler. - Equivalent behavior in current plugin interface: In
origin/v8(util/plugin/plugin.go:34),IsPluginCommandalso inspected onlyosArgs[0]. - Suggested fix: Although pre-existing, this can be improved by parsing the plugin command token from the args or tracking it separately from migration blockers.
There was a problem hiding this comment.
Fixed here, even though it's pre-existing (util/plugin.IsPluginCommand also only looked at osArgs[0]). Leading arguments starting with -/-- are now treated as global options and skipped to find the command (runner.CommandArgs). The plugin receives the command and all arguments after it, so cf -v my-plugin-cmd --opt x runs my-plugin-cmd with --opt x. Unknown commands now also report the actual command name instead of the flag.
(69b674746)
…avoid panics during plugin-uninstall
…lugin GetServices
|
All inline review threads are addressed (replies on each thread). Beyond the review, I synced the branch with
Compatibility notes (worth reading for plugin authors):
Proposed for follow-up PRs (out of scope here):
Still not populated (known gaps): Testing: unit tests for every change (RED → GREEN), |
Thank you for contributing to the CF CLI! Please read the following:
If your contribution falls under a company CLA but your membership is not public, expect delays while we confirm.
Note: Please create separate PR for every branch (main and v8) as needed.
Description of the Change
While this PR may not be considered for merging, it intends to support #3621 showing how the current v8 plugin implementation may be refactored in order to fully support v3 calls and prepare the project to completely drop any v2 related code. A main branch PR has not been created unless the pr is deemed worthy and a main pr is requested (such a PR would/should probably already have the deprecated methods fully removed).
Changes of note are:
Some caveats:
Why Is This PR Valuable?
It finally drops the dependency of the V2 cc API and allow plugin backward compatibility which may remain unchanged (although they shouldn't) unless they explicitly invoke V2 endpoints. From the CLI scope perspective, it's the next evolving step.
Applicable Issues
#3621
How Urgent Is The Change?
V2 is bound to be fully decommissioned and several foundation installations already have the V2 api disabled. This will allow the v2 api to be fully decommissioned while having the CLI fully compatible (v3 plugin compatibility, if it directly calls v2 endpoints, falls under the responsibility of the plugin maintainers)
Other Relevant Parties
All plugin users/developers