alexandrefimov commented on issue #7511:
URL: https://github.com/apache/kyuubi/issues/7511#issuecomment-5215521163
## Spark Connect in the Kyuubi Spark engine — design for the first slice
Spark Connect does not use `user_id` for anything except the session key: a
plan
is executed and authorized as the driver's own user. That single fact shapes
most
of what follows, so the evidence first.
Static checks on 4.0.3, 4.1.2 and 4.2.0: the `org.apache.spark.sql.connect`
package references neither `UserGroupInformation` nor `CurrentUserContext`,
and
`ExecuteThreadRunner` sets only `callSite.short` / `callSite.long` and job
tags.
Two runtime checks, both on 4.0.3: a driver started with `--proxy-user
testalice`
serving a client connected as `user_id=bob` returns `testalice` from
`current_user()` and `session_user()` — `SPARK_USER` was unset, so that value
comes from `UserGroupInformation.getCurrentUser` on the planning thread; and
with
`kyuubi-spark-authz` loaded into the same driver, a `CREATE TABLE` from the
`bob`
client is denied with `Permission denied: user [testalice] does not have
[create]
privilege on [default/poc_authz_probe]`.
At USER share level with `kyuubi.engine.doAs.enabled=true` (the default), the
driver already runs as the end user, so authorization identity matches the
Thrift
path — by deployment topology rather than by propagation. Three limitations
remain, and I would rather document them than paper over them:
- with `doAs` disabled, the Thrift path still authorizes as the session user
through the `kyuubi.session.user` local property set in
`SparkOperation.withLocalProperties` (`SparkOperation.scala:164`) and read
first by `AuthZUtils.getAuthzUgi`; Connect sets no such property, so it
would
authorize as the server user — a regression relative to Thrift at the same
share level;
- Connect operations bypass `withLocalProperties` entirely
(`SparkOperation.scala:158-194`), so the scheduler pool — tenant resource
policies — the statement id and the session-user signature are absent for
Connect queries;
- with `kyuubi.session.user.sign.enabled`, `AuthZUtils.getAuthzUgi` calls
`verifyKyuubiSessionUser` with a null user and throws
`Invalid user identifier [null]` (`AuthZUtils.scala:51-53`, `:123-133`). It
fails closed, but the feature is unusable in those deployments.
### Scope
As you outlined it. Engines are still provisioned through the existing
session
path; this slice starts Connect inside the engine and advertises the
endpoint.
Routing a Connect client to an engine is gateway-side work and out of scope,
together with YARN pool integration, share levels other than USER, and token
rotation.
### Starting Connect
`spark-connect_2.13-<version>.jar` ships in the standard Spark 4.x
distribution
and `org.apache.spark.sql.connect.SparkConnectPlugin` implements
`SparkPlugin`,
so starting the service is configuration only. `SparkProcessBuilder` appends
the
plugin class to `spark.plugins` — appends, since users set it for Delta and
others — along with the Connect settings, and no Spark Connect code or
dependency
enters `kyuubi-server`.
Reading state back — the bound port, the live sessions — does need the
Connect
classes, and that has to go through reflection: `SparkConnectService`'s Scala
signatures reference `io.grpc`, so a compile-time reference fails with
`Symbol 'term io.grpc' is missing from the classpath` unless grpc is added
to the
engine module's compile classpath. Reflection also keeps the default build
inert,
since the module compiles against the active Spark profile (`spark-3.5` by
default) and has no Spark-version-specific source directories.
The reflection targets are identical on 4.0.3, 4.1.2 and 4.2.0 —
`SparkConnectService$.localPort()` / `hostAddress()` / `started()`,
`sessionManager().listActiveSessions()`, `listActiveExecutions()`,
`stop(Option[Long], Option[TimeUnit])` — so I expect one implementation to
cover
the 4.x profiles in the tree. A capability check at startup fails the engine
with
an explicit message if they are missing, rather than degrading silently.
Configuration entry: `kyuubi.engine.spark.connect.enabled`, default `false`,
`version("1.12.0")`, immutable and admin-controlled. The engine key
(`EngineRef.engineSpace`, `EngineRef.scala:187-194`) does not include it, so
a
per-session value would let one user's sessions disagree about whether their
engine has Connect — the second session would silently reuse an engine with
no
endpoint. Immutable keeps the flag uniform per deployment; a client still
tells
the two apart by whether the endpoint attribute is present in discovery,
since
changing the flag does not retrofit a running engine. `settings.md` is
regenerated in the same PR as the entry.
### Discovery
`SparkTBinaryFrontendService.attributes`
(`externals/kyuubi-spark-sql-engine/.../SparkTBinaryFrontendService.scala:99-108`)
already publishes `kyuubi.engine.id` and `kyuubi.engine.url`. This slice
adds the
bound endpoint as `kyuubi.engine.connect.url` — a new `KyuubiReservedKeys`
constant next to `KYUUBI_ENGINE_URL` (`KyuubiReservedKeys.scala:34-36`) —
with
`host:port` as the value, read back after the plugin binds so that
`spark.connect.grpc.binding.port=0` works.
The value shape is not a style choice. Attributes are serialised into a
single
discovery node-name element, `k=v` joined with `;`
(`kyuubi-ha/.../ZookeeperDiscoveryClient.scala:361-366`, same in
`EtcdDiscoveryClient.scala:332-338`), so a value can contain neither `;` nor
`/`
— `=` is safe, since decoding splits with a limit of two
(`ZookeeperDiscoveryClient.scala:206-208`). That is why `kyuubi.engine.url`
is
already stored with its scheme stripped
(`SparkTBinaryFrontendService.scala:105-108`), and why the Connect endpoint
is
published without an `sc://` prefix. The same encoding means the endpoint
cannot
be deregistered independently of the Thrift node — removing one removes both,
which is what drain below relies on. Attributes also already surface through
`GET /api/v1/admin/engine` (`AdminResource.scala:377`), so operators see the
endpoint with no new REST work.
Ownership is already implied by the discovery path for USER share level
(`/serverSpace_version_USER_engineType/<user>[/<subdomain>]`), so this slice
publishes only the endpoint. Whether the attribute set should also carry an
explicit owner label is a question I would rather settle together with the
gateway-side placement work. No secrets are published here.
### Lifecycle
Engine termination is driven by `SessionManager.getActiveUserSessionCount`
(`kyuubi-common/.../session/SessionManager.scala:167`) through four call
sites:
the idle checker (`SessionManager.scala:346-373`), the max-lifetime checker
(`SparkSQLEngine.scala:165-214`), `gracefulStop`
(`SparkSQLEngine.scala:123-138`)
and `ServiceDiscovery.stopGracefully`
(`kyuubi-ha/.../ServiceDiscovery.scala:68-74`,
reached on discovery connection loss or node deletion — the `kyuubi-ctl`
path).
`startFastFailChecker` uses the same counter but only runs at CONNECTION
share
level (`SparkSQLEngine.scala:88-91`), so it is out of reach for this slice.
A Connect session is invisible to all of them. Connect exposes what is
needed:
`listActiveSessions()` returns per-session records carrying `userId` and
`lastAccessTimeMs`, and `listActiveExecutions()` returns either the active
executions or — when none is active — a `System.nanoTime()` reading of the
last
completion. That reading is monotonic and unrelated to wall clock, so it has
to
be converted against a nanoTime baseline before it can feed the idle timer,
which
compares against `System.currentTimeMillis()` (`SessionManager.scala:355`).
The plan:
`SparkSQLSessionManager` includes live Connect sessions in the active count,
which reaches all four call sites without touching `kyuubi-common`. The cost
is
that `getActiveUserSessionCount` acquires a second meaning: it no longer
agrees
with `allSessions()`, and its other consumers —
`ServiceDiscovery.stopGracefully`,
the "current opening sessions" log line, the engine UI — inherit that. The
alternative is a separate overridable predicate (`hasActiveWork` or similar)
consulted next to the counter, which keeps the counter honest at the cost of
touching every call site. See question 1.
Idle detection also needs a last-activity source. `latestLogoutTime` is
readable,
but it is advanced only inside `SessionManager.closeSession`
(`SessionManager.scala:124-131`) and at checker start (`:346-348`), so a
closing
Connect session leaves it untouched and the idle timer is already expired
when
the last one goes away.
The max-lifetime path needs an explicit answer rather than the counter alone.
After `maxLifetime + gracefulPeriod` it force-closes sessions drawn from
`allSessions()` (`SparkSQLEngine.scala:184-202`); Connect sessions are not in
`handleToSession`, so they would never be force-closed while the counter
never
reaches zero — an engine past its max lifetime with one idle-but-open Connect
session would never terminate. Either the graceful period force-closes
Connect
sessions through `SparkConnectService` as well, or the slice relies on
`spark.connect.session.manager.defaultSessionTimeout` and says so.
Drain: `gracefulStop` deregisters from discovery — which removes the Connect
endpoint with the node, as above — then waits for Connect sessions and
executions
to finish, and only then calls `SparkConnectService.stop(grace, unit)`. The
order
matters: that call starts with a gRPC `shutdown()` before awaiting
termination,
and afterwards a client is answered with `UNAVAILABLE`; since every Connect
statement is a separate RPC, stopping the service first would cut sessions
that
are still draining.
### Backend authentication
`spark.connect.authenticate.token` is present in the 4.0, 4.1 and 4.2 lines I
checked; on 4.0.3 a client connecting without it is rejected with
`UNAUTHENTICATED`. With Connect enabled the engine should refuse to start
unless
a token is configured — otherwise flipping the flag yields an open gRPC
endpoint
that accepts any `user_id` and executes with the engine owner's privileges,
which
is exactly the failure the section above describes. The check has to cover
the
runtime as well as the configuration: the key only exists from 4.0, while the
Connect plugin itself is present on 3.5, so a capability check that probes
only
the reflection targets would pass on a 3.5 runtime with the token silently
ignored. Gating on `KyuubiSparkUtil.SPARK_ENGINE_RUNTIME_VERSION >= "4.0"`
(`KyuubiSparkUtil.scala:114`) closes that.
On delivery: configuration reaching the engine goes through argv —
`SparkProcessBuilder.commands` expands
`conf.getEngineConf(EngineType.SPARK_SQL)`
into `--conf k=v` pairs (`SparkProcessBuilder.scala:133-157`) and
`Utils.redactCommandLineArgs` (`Utils.scala:329`) only touches the logged
copy —
so a SPARK-audience token key would be readable in `ps` by any user on the
host.
The precedent for avoiding that is already in the tree:
`FlinkProcessBuilder.generateTokenFile` (`FlinkProcessBuilder.scala:281-297`)
writes a generated credential to a file and passes the *path* through an env
var,
cleaning up in `close`. I would follow that shape, with the token placed into
`_sparkConf` before `createSpark()` (`SparkSQLEngine.scala:328-334`), since
the
plugin binds inside `getOrCreate`.
I am aware this argues against an existing pattern: engine credentials travel
through the launch conf today (`KyuubiSessionImpl.scala:137-139`, read back
at
`SparkSQLEngine.scala:328`). The difference I would draw is that those are
per-session Hadoop delegation tokens with their own expiry, while this one
is the
only thing standing between an unauthenticated client and an engine running
with
a specific user's privileges. Note also that the Spark UI and event logs are
covered by default — the engine's default `spark.redaction.regex` already
includes `token` (`SparkSQLEngine.scala:273-276`; an operator who overrides
the
pattern keeps that responsibility) — so the file buys exactly one thing,
keeping
the secret out of argv.
One token per engine rather than one per deployment, so that a leaked token
costs
a single engine rather than every engine sharing it. In cluster deployments
the
file still has to reach the driver host (staging dir, `spark.files`, or a
Secret);
which mechanism to use is open — see question 2.
### Tests and cost
The tests that exercise a live session — keep-alive, drain and the token
rejection — need a Spark Connect client on the test classpath. There is no
`spark-connect` dependency in the tree today, so that is a new test-scoped
dependency plus a runtime-version `assume` in the style of
`SparkOperationSuite.scala:200`. It is the largest hidden cost in the slice
and I
would rather name it up front than discover it in review.
With the flag off, behaviour is unchanged; with it on, the Connect port
binds and
the endpoint is advertised, and the node carries no secret. An engine with a
live
Connect session is not reaped by the idle timeout, and drain removes the
endpoint
before waiting for active work. A client connecting directly without the
token is
rejected.
### PRs
Tests ship with the feature in each PR, and `settings.md` is regenerated in
the
PR that adds the config entry.
1. start Connect: config entry, process builder, capability check,
`settings.md`;
2. advertise the endpoint through discovery;
3. lifecycle: keep-alive, max-lifetime handling and drain;
4. backend token at engine launch;
5. deployment documentation and the share-level follow-up note.
Happy to merge 1 and 2 if you would rather review one self-contained slice.
### Open questions
1. Connect activity in the lifecycle checkers: include Connect sessions in
`getActiveUserSessionCount` (reaches all four call sites, but the counter
stops agreeing with `allSessions()` for its other consumers), or add a
separate overridable predicate next to it and update the call sites?
2. Secret delivery: follow `FlinkProcessBuilder.generateTokenFile` —
generated
file plus path in an env var — and if so, which distribution mechanism for
cluster modes?
3. Fail at startup for non-USER share levels and for a missing token, as
proposed, or warn and continue?
4. The 4.0, 4.1 and 4.2 jobs already run the engine suites
(`.github/workflows/master.yml:64-81`), so I would gate the new tests with
`assume(SPARK_ENGINE_RUNTIME_VERSION >= "4.0")` and let them run in all
three — any reason to restrict that?
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]