Skip to content

feat: add internal token rotation for clients - #695

Merged
mangelajo merged 2 commits into
mainfrom
internal-secret-rotate
May 22, 2026
Merged

feat: add internal token rotation for clients#695
mangelajo merged 2 commits into
mainfrom
internal-secret-rotate

Conversation

@bennyz

@bennyz bennyz commented May 19, 2026

Copy link
Copy Markdown
Member

Wire the existing but unused tokenLifetime config into Signer.Token(), add a RotateToken RPC to ClientService so clients can rotate their own internal JWT without being deleted and recreated, and add an admin path (jmp admin rotate client) that works via K8s API for expired tokens.

  • jmp auth rotate client self-rotates via gRPC (needs valid token)
  • jmp admin rotate client <name> --save admin rotates via K8s API

@coderabbitai

coderabbitai Bot commented May 19, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Implements configurable OIDC token lifetime and full token rotation: adds RotateToken RPC, backend handler that signs and rotates client credential secrets, Kubernetes client rotation helper, Python SDK and CLI integrations, and unit/integration/e2e tests.

Changes

Token rotation feature

Layer / File(s) Summary
Protocol contract: RotateToken RPC and messages
protocol/proto/jumpstarter/client/v1/client.proto
Adds RotateToken RPC to ClientService with HTTP POST binding and new RotateTokenRequest/RotateTokenResponse messages.
Generated protocol bindings
python/packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2.py, client_pb2.pyi, client_pb2_grpc.py, client_pb2_grpc.pyi
Regenerated Python protobuf and gRPC artifacts: DESCRIPTOR/message stubs, RPC wiring, and typing stubs for RotateToken across sync/async stubs and servicer.
OIDC token lifetime configuration
controller/internal/oidc/op.go, controller/internal/config/oidc.go, controller/internal/oidc/op_test.go
Signer gains configurable tokenLifetime and SetTokenLifetime; Token uses configured lifetime with 365-day fallback. Config loader parses Internal.TokenLifetime and applies it. Unit tests validate default/custom lifetimes and determinism.
Backend RotateToken RPC handler
controller/internal/service/client/v1/client_service.go, controller/internal/service/controller_service.go, controller/cmd/main.go
ClientService accepts Signer dependency and implements RotateToken: generates signed token, patches client credential Secret (token), extracts expiry from JWT claims, and returns token+expiry. ControllerService/main wire the Signer.
Kubernetes client API: rotate_client_token
python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients.py, rotate_test.py
ClientsV1Alpha1Api.rotate_client_token deletes the referenced secret, polls for regenerated secret with data["token"], decodes and returns token. Tests cover success, missing credential, timeout, and non-404 error propagation.
gRPC client wrapper
python/packages/jumpstarter/jumpstarter/client/grpc.py
ClientService.RotateToken client wrapper performs namespace-scoped gRPC RotateToken call, translates exceptions, and returns token from response.
Client config integration
python/packages/jumpstarter/jumpstarter/config/client.py
ClientConfigV1Alpha1.rotate_token creates gRPC ClientService and returns RotateToken result, wrapped with blocking/async compatibility and error handling.
Admin CLI: rotate client command
python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/rotate.py, rotate_test.py, __init__.py
New admin rotate client Click command invoking rotate_client_token; supports --save/--out to persist rotated token into local ClientConfig or file. Integration tests validate save/load, file output, and formatting options.
User CLI: auth rotate command
python/packages/jumpstarter-cli/jumpstarter_cli/auth.py, auth_test.py
New auth rotate subcommand validates current token, rejects rotation if expired, calls config.rotate_token, persists updated token, and prints expiry or remaining time. Tests verify missing token, expired rejection, and successful rotation.
End-to-end tests
e2e/test/e2e_test.go
Ginkgo e2e suite covering admin and user token rotation flows, config persistence, double-rotation distinctness, missing-client error, and post-rotation authentication checks.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Suggested labels

administration

Suggested reviewers

  • mangelajo
  • bkhizgiy
  • evakhoni

Poem

🐰 I hopped through code to spin a key anew,
Secrets swapped and JWTs now fresh as dew,
From signer config to CLI's friendly tap,
Tokens turn and servers clap—no gap,
A small rabbit's cheer for secure renew!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 35.38% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat: add internal token rotation for clients' clearly and concisely summarizes the main change: adding token rotation functionality for clients, which is exactly what the changeset implements across multiple files.
Description check ✅ Passed The description is directly related to the changeset, explaining the key implementation details (wiring tokenLifetime config, adding RotateToken RPC, adding admin CLI path) and user-facing behaviors (jmp auth rotate and jmp admin rotate client commands).
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch internal-secret-rotate

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 golangci-lint (2.12.2)

level=error msg="[linters_context] typechecking error: pattern ./...: directory prefix . does not contain main module or its selected dependencies"


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🧹 Nitpick comments (1)
python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/rotate_test.py (1)

75-106: ⚡ Quick win

Mock asyncio.sleep in timeout test to avoid real 10s wait.

This test currently waits through the full polling window; patching sleep keeps coverage while reducing CI latency.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/rotate_test.py`
around lines 75 - 106, The test test_rotate_client_token_timeout currently
performs real waits; patch asyncio.sleep inside the test (before awaiting
api.rotate_client_token) to a no-op async function or AsyncMock so the polling
loop in ClientsV1Alpha1Api.rotate_client_token runs without real delays; ensure
the mock is applied (and restored if needed) so the test still triggers the
timeout logic while avoiding the real 10s sleeps.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@controller/internal/config/oidc.go`:
- Around line 28-34: The TokenLifetime parsing branch allows negative or zero
durations which can produce already-expired tokens; in the block where you call
ParseDuration on config.Internal.TokenLifetime and then signer.SetTokenLifetime
(the TokenLifetime handling code using ParseDuration and
signer.SetTokenLifetime), validate the parsed lifetime and reject non-positive
values by returning an error (with a clear message) instead of setting the
signer; ensure the error is returned to the caller so callers cannot proceed
with a zero or negative TokenLifetime.

In `@controller/internal/service/client/v1/client_service.go`:
- Around line 421-423: When rotating the credential token, do not replace
secret.Data entirely; instead preserve existing keys and only set/update the
"token" entry. Locate the code using the secret variable and replace the
assignment secret.Data = map[string][]byte{ "token": []byte(token) } with logic
that ensures secret.Data is non-nil (if secret.Data == nil { secret.Data =
map[string][]byte{} }) and then updates the single key (secret.Data["token"] =
[]byte(token)), preserving any other keys before continuing with the existing
save/update call.
- Around line 395-407: The RotateToken method dereferences req and s.Signer
without nil checks which can cause panics; add a guard at the start of
RotateToken to return a descriptive error if req == nil, and check s.Signer (or
s.Signer.Token usage) before calling s.Signer.Token to return an error if the
signer is nil; ensure you still parse namespace via
utils.ParseNamespaceIdentifier and call s.AuthClient only after these guards so
the flow (namespace parsing, AuthClient, s.Signer.Token with
jclient.InternalSubject()) remains unchanged otherwise.

In `@python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/rotate.py`:
- Around line 79-82: The code currently only catches ApiException and
ConfigException around rotate_client_token, but rotate_client_token can raise
plain Exceptions (e.g., timeout/no credential) causing raw CLI tracebacks; add a
final broad except Exception as e clause after the existing except blocks to
catch non-Kubernetes errors from rotate_client_token and handle them
gracefully—either call a new or existing error handler (e.g.,
handle_generic_rotation_exception(e) or reuse processLogger.error plus
sys.exit(1)) so the CLI logs a clear message and exits cleanly; reference
rotate_client_token, handle_k8s_api_exception, and handle_k8s_config_exception
when updating the try/except block.

In `@python/packages/jumpstarter-cli/jumpstarter_cli/auth.py`:
- Around line 149-159: The code currently assigns and persists the rotated token
immediately (config.token = new_token; ClientConfigV1Alpha1.save(config)) before
validating it; instead, validate new_token first by ensuring it's non-empty,
that get_token_remaining_seconds(new_token) returns a non-None value and that
decode_jwt(new_token) yields an expected payload (e.g., contains "exp") before
mutating config and calling ClientConfigV1Alpha1.save; if validation fails, do
not set config.token or save, surface an error (raise or click.echo and exit)
and avoid persisting a malformed token—make these checks around the result of
rotate_token() and only persist on successful validation, referencing
rotate_token, new_token, get_token_remaining_seconds, decode_jwt, config.token,
and ClientConfigV1Alpha1.save.

In `@python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients.py`:
- Around line 166-168: The code in the block that calls await
self.get_client(name) dereferences client.status.credential and can raise
AttributeError if client.status is None; update this check in the method (the
caller of get_client) to first ensure client.status is not None (e.g., if
client.status is None or getattr(client.status, "credential", None) is None) and
then raise the intended Exception(f"Client '{name}' has no credential secret")
when missing; alternatively wrap the access in a safe getattr or try/except to
convert AttributeError into the same user-facing exception while referencing the
existing client, get_client and client.status symbols.
- Around line 175-180: The current polling loop swallows all exceptions (the
try/except around self.core_api.read_namespaced_secret), which hides real
errors; replace the broad except Exception with targeted handling: catch
kubernetes.client.rest.ApiException (or the specific k8s API exception type) and
only ignore/continue for expected transient statuses (e.g., 404 NotFound while
the secret is not yet created), but for any other ApiException or any other
exception, log the error (use the existing logger or raise) and re-raise so real
failures surface; keep the logic that decodes secret.data["token"] unchanged and
ensure you import the ApiException type used for discrimination.

---

Nitpick comments:
In
`@python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/rotate_test.py`:
- Around line 75-106: The test test_rotate_client_token_timeout currently
performs real waits; patch asyncio.sleep inside the test (before awaiting
api.rotate_client_token) to a no-op async function or AsyncMock so the polling
loop in ClientsV1Alpha1Api.rotate_client_token runs without real delays; ensure
the mock is applied (and restored if needed) so the test still triggers the
timeout logic while avoiding the real 10s sleeps.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 41dd6d7a-a098-4b11-b5ef-9f7dd949ee06

📥 Commits

Reviewing files that changed from the base of the PR and between c22dfe8 and cdbf6f2.

⛔ Files ignored due to path filters (5)
  • controller/internal/protocol/jumpstarter/client/v1/client.pb.go is excluded by !**/*.pb.go
  • controller/internal/protocol/jumpstarter/client/v1/client.pb.gw.go is excluded by !**/*.pb.gw.go
  • controller/internal/protocol/jumpstarter/client/v1/client_grpc.pb.go is excluded by !**/*.pb.go
  • controller/internal/protocol/jumpstarter/v1/jumpstarter_grpc.pb.go is excluded by !**/*.pb.go
  • controller/internal/protocol/jumpstarter/v1/router_grpc.pb.go is excluded by !**/*.pb.go
📒 Files selected for processing (19)
  • controller/cmd/main.go
  • controller/internal/config/oidc.go
  • controller/internal/oidc/op.go
  • controller/internal/service/client/v1/client_service.go
  • controller/internal/service/controller_service.go
  • protocol/proto/jumpstarter/client/v1/client.proto
  • python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/__init__.py
  • python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/rotate.py
  • python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/rotate_test.py
  • python/packages/jumpstarter-cli/jumpstarter_cli/auth.py
  • python/packages/jumpstarter-cli/jumpstarter_cli/auth_test.py
  • python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients.py
  • python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/rotate_test.py
  • python/packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2.py
  • python/packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2.pyi
  • python/packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2_grpc.py
  • python/packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2_grpc.pyi
  • python/packages/jumpstarter/jumpstarter/client/grpc.py
  • python/packages/jumpstarter/jumpstarter/config/client.py

Comment thread controller/internal/config/oidc.go
Comment thread controller/internal/service/client/v1/client_service.go
Comment thread controller/internal/service/client/v1/client_service.go Outdated
Comment thread python/packages/jumpstarter-cli/jumpstarter_cli/auth.py
Comment thread python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients.py Outdated
@bennyz
bennyz force-pushed the internal-secret-rotate branch from cdbf6f2 to 519f658 Compare May 19, 2026 17:18
@mangelajo

Copy link
Copy Markdown
Member

Thanks benny!, this is awesome, can we add a bunch of E2E tests to verify admin and client rotation?, leave docs for later as @raballew is doing a small doc refactor :)

@mangelajo mangelajo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we add some E2E tests for the admin and client sides? looks awesome

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
e2e/test/e2e_test.go (1)

286-290: ⚡ Quick win

Strengthen the negative-path assertion for missing client.

This test currently passes for any failure reason. Assert an expected error substring (e.g., not found) to ensure it validates the intended behavior.

Suggested test tightening
 	It("admin rotate fails for non-existent client", func() {
 		ns := Namespace()
-		_, err := Jmp("admin", "rotate", "client", "-n", ns, "does-not-exist")
+		out, err := Jmp("admin", "rotate", "client", "-n", ns, "does-not-exist")
 		Expect(err).To(HaveOccurred())
+		Expect(strings.ToLower(out)).To(ContainSubstring("not found"))
 	})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e/test/e2e_test.go` around lines 286 - 290, The test It("admin rotate fails
for non-existent client" currently only asserts that an error occurred; tighten
it to assert the error message contains an expected substring (e.g., "not
found") so the failure is for the missing client and not some other reason:
after calling Jmp("admin", "rotate", "client", "-n", ns, "does-not-exist")
update the assertion to check the error string (for example using
Expect(err).To(MatchError(ContainSubstring("not found"))) or
Expect(err).To(HaveOccurred()); Expect(err.Error()).To(ContainSubstring("not
found"))), referencing the test case and the Jmp call to locate where to change
the assertion.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@controller/internal/oidc/op_test.go`:
- Around line 118-121: The test currently ignores the error returned by
s1.Token("subject"); update the test so you capture and assert the error from
s1.Token (e.g., check err != nil and call t.Fatalf/t.Fatal with the error)
before calling s2.Validate, so failures in s1.Token are reported clearly; refer
to s1.Token, token1, err and s2.Validate to locate and adjust the test logic.

---

Nitpick comments:
In `@e2e/test/e2e_test.go`:
- Around line 286-290: The test It("admin rotate fails for non-existent client"
currently only asserts that an error occurred; tighten it to assert the error
message contains an expected substring (e.g., "not found") so the failure is for
the missing client and not some other reason: after calling Jmp("admin",
"rotate", "client", "-n", ns, "does-not-exist") update the assertion to check
the error string (for example using
Expect(err).To(MatchError(ContainSubstring("not found"))) or
Expect(err).To(HaveOccurred()); Expect(err.Error()).To(ContainSubstring("not
found"))), referencing the test case and the Jmp call to locate where to change
the assertion.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 9df606f5-d70a-49ad-9278-f77de50bca5c

📥 Commits

Reviewing files that changed from the base of the PR and between cdbf6f2 and 0f16816.

⛔ Files ignored due to path filters (5)
  • controller/internal/protocol/jumpstarter/client/v1/client.pb.go is excluded by !**/*.pb.go
  • controller/internal/protocol/jumpstarter/client/v1/client.pb.gw.go is excluded by !**/*.pb.gw.go
  • controller/internal/protocol/jumpstarter/client/v1/client_grpc.pb.go is excluded by !**/*.pb.go
  • controller/internal/protocol/jumpstarter/v1/jumpstarter_grpc.pb.go is excluded by !**/*.pb.go
  • controller/internal/protocol/jumpstarter/v1/router_grpc.pb.go is excluded by !**/*.pb.go
📒 Files selected for processing (21)
  • controller/cmd/main.go
  • controller/internal/config/oidc.go
  • controller/internal/oidc/op.go
  • controller/internal/oidc/op_test.go
  • controller/internal/service/client/v1/client_service.go
  • controller/internal/service/controller_service.go
  • e2e/test/e2e_test.go
  • protocol/proto/jumpstarter/client/v1/client.proto
  • python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/__init__.py
  • python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/rotate.py
  • python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/rotate_test.py
  • python/packages/jumpstarter-cli/jumpstarter_cli/auth.py
  • python/packages/jumpstarter-cli/jumpstarter_cli/auth_test.py
  • python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients.py
  • python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/rotate_test.py
  • python/packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2.py
  • python/packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2.pyi
  • python/packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2_grpc.py
  • python/packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2_grpc.pyi
  • python/packages/jumpstarter/jumpstarter/client/grpc.py
  • python/packages/jumpstarter/jumpstarter/config/client.py
✅ Files skipped from review due to trivial changes (3)
  • python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/init.py
  • python/packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2_grpc.py
  • python/packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2.py

Comment thread controller/internal/oidc/op_test.go Outdated
Comment on lines +183 to +184
except Exception:
raise

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isnt this functionally identical to not having the handler at all?

Comment thread controller/internal/oidc/op.go Outdated
Comment thread controller/internal/service/client/v1/client_service.go
bennyz added 2 commits May 21, 2026 08:35
Wire the existing but unused tokenLifetime config into Signer.Token(),
add a RotateToken RPC to ClientService so clients can rotate their own
internal JWT without being deleted and recreated, and add an admin path
(jmp admin rotate client) that works via K8s API for expired tokens.

- `jmp auth rotate` client self-rotates via gRPC (needs valid token)
- `jmp admin rotate client <name> --save` admin rotates via K8s API
@bennyz
bennyz force-pushed the internal-secret-rotate branch from 0f16816 to b338136 Compare May 21, 2026 05:42

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
controller/internal/service/client/v1/client_service.go (1)

396-408: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add nil guards before request/signer dereference.

Line 397 and Line 407 can panic when req or s.Signer is nil. Return gRPC status errors instead.

Proposed fix
 func (s *ClientService) RotateToken(ctx context.Context, req *cpb.RotateTokenRequest) (*cpb.RotateTokenResponse, error) {
+	if req == nil {
+		return nil, status.Error(codes.InvalidArgument, "request is required")
+	}
+	if s.Signer == nil {
+		return nil, status.Error(codes.FailedPrecondition, "token signer is not configured")
+	}
+
 	namespace, err := utils.ParseNamespaceIdentifier(req.Parent)
 	if err != nil {
 		return nil, err
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@controller/internal/service/client/v1/client_service.go` around lines 396 -
408, In RotateToken, add nil guards for the incoming request and the service
signer: check that req is not nil before accessing req.Parent and return a gRPC
status error (e.g., codes.InvalidArgument) if it is, and check that s.Signer is
not nil before calling s.Signer.Token and return an appropriate gRPC status
error (e.g., codes.FailedPrecondition or codes.Unavailable) if missing; update
the beginning of RotateToken (function RotateToken, references to req.Parent and
s.Signer.Token) to perform these checks and return clear status.Error responses
instead of allowing a panic.
🧹 Nitpick comments (2)
python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/rotate_test.py (1)

86-92: ⚡ Quick win

Strengthen the name-only output expectation.

Line 91 only proves status text is suppressed; it doesn’t verify the promised name-only output. Assert the expected name to prevent silent regressions.

Proposed test tightening
 def test_rotate_client_name_only_output(mock_rotate, _mock_kube):
     """--output name prints only name."""
     runner = CliRunner()
     result = runner.invoke(rotate, ["client", "my-client", "--output", "name"])
     assert result.exit_code == 0
-    assert "Rotating" not in result.output
+    assert result.output.strip() == "my-client"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/rotate_test.py`
around lines 86 - 92, Update test_rotate_client_name_only_output to not only
assert absence of status text but also verify the actual name-only output; after
invoking rotate (in test_rotate_client_name_only_output and using the same
runner.invoke call), add an assertion that the CLI output equals the expected
resource name ("my-client") when trimmed (i.e. strip whitespace/newline) so the
test fails on any extra text or missing name, while keeping the existing assert
that "Rotating" is not in result.output.
e2e/test/e2e_test.go (1)

286-290: ⚡ Quick win

Assert the expected failure reason for the missing-client case.

Line 289 currently accepts any error, so unrelated failures can pass this test. Check for a not-found style message to verify the intended behavior.

Proposed assertion improvement
 	It("admin rotate fails for non-existent client", func() {
 		ns := Namespace()
-		_, err := Jmp("admin", "rotate", "client", "-n", ns, "does-not-exist")
+		out, err := Jmp("admin", "rotate", "client", "-n", ns, "does-not-exist")
 		Expect(err).To(HaveOccurred())
+		Expect(strings.ToLower(out)).To(ContainSubstring("not found"))
 	})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e/test/e2e_test.go` around lines 286 - 290, The test "admin rotate fails
for non-existent client" currently only checks that an error occurred; change
the assertion to validate the failure reason by asserting the returned error
from Jmp("admin", "rotate", "client", "-n", ns, "does-not-exist") contains a
not-found message (e.g., "not found" or "does not exist"). Replace the loose
Expect(err).To(HaveOccurred()) with a stricter assertion like
Expect(err).To(MatchError(ContainSubstring("not found"))) or
Expect(err.Error()).To(ContainSubstring("does not exist")) so the test ensures
the specific missing-client error path is exercised.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients.py`:
- Around line 172-183: In rotate_client_token, after calling
core_api.delete_namespaced_secret, change the polling logic around
core_api.read_namespaced_secret so that you do not accept the first successful
read as the new token; instead require that at least one ApiException with
status 404 has been observed after deletion before returning a Secret.token.
Update the loop in rotate_client_token (which currently uses
count/CREATE_CLIENT_COUNT and catches ApiException) to track a boolean like
sawNotFound that flips true when e.status == 404, and only decode/return
base64.b64decode(secret.data["token"]).decode("utf8") if sawNotFound is true and
secret.data contains "token"; still re-raise non-404 ApiExceptions and keep the
existing timeout/count behavior.

---

Duplicate comments:
In `@controller/internal/service/client/v1/client_service.go`:
- Around line 396-408: In RotateToken, add nil guards for the incoming request
and the service signer: check that req is not nil before accessing req.Parent
and return a gRPC status error (e.g., codes.InvalidArgument) if it is, and check
that s.Signer is not nil before calling s.Signer.Token and return an appropriate
gRPC status error (e.g., codes.FailedPrecondition or codes.Unavailable) if
missing; update the beginning of RotateToken (function RotateToken, references
to req.Parent and s.Signer.Token) to perform these checks and return clear
status.Error responses instead of allowing a panic.

---

Nitpick comments:
In `@e2e/test/e2e_test.go`:
- Around line 286-290: The test "admin rotate fails for non-existent client"
currently only checks that an error occurred; change the assertion to validate
the failure reason by asserting the returned error from Jmp("admin", "rotate",
"client", "-n", ns, "does-not-exist") contains a not-found message (e.g., "not
found" or "does not exist"). Replace the loose Expect(err).To(HaveOccurred())
with a stricter assertion like Expect(err).To(MatchError(ContainSubstring("not
found"))) or Expect(err.Error()).To(ContainSubstring("does not exist")) so the
test ensures the specific missing-client error path is exercised.

In `@python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/rotate_test.py`:
- Around line 86-92: Update test_rotate_client_name_only_output to not only
assert absence of status text but also verify the actual name-only output; after
invoking rotate (in test_rotate_client_name_only_output and using the same
runner.invoke call), add an assertion that the CLI output equals the expected
resource name ("my-client") when trimmed (i.e. strip whitespace/newline) so the
test fails on any extra text or missing name, while keeping the existing assert
that "Rotating" is not in result.output.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 9e287d6f-463d-4f84-80bd-38bbf87841f4

📥 Commits

Reviewing files that changed from the base of the PR and between 0f16816 and b338136.

⛔ Files ignored due to path filters (5)
  • controller/internal/protocol/jumpstarter/client/v1/client.pb.go is excluded by !**/*.pb.go
  • controller/internal/protocol/jumpstarter/client/v1/client.pb.gw.go is excluded by !**/*.pb.gw.go
  • controller/internal/protocol/jumpstarter/client/v1/client_grpc.pb.go is excluded by !**/*.pb.go
  • controller/internal/protocol/jumpstarter/v1/jumpstarter_grpc.pb.go is excluded by !**/*.pb.go
  • controller/internal/protocol/jumpstarter/v1/router_grpc.pb.go is excluded by !**/*.pb.go
📒 Files selected for processing (21)
  • controller/cmd/main.go
  • controller/internal/config/oidc.go
  • controller/internal/oidc/op.go
  • controller/internal/oidc/op_test.go
  • controller/internal/service/client/v1/client_service.go
  • controller/internal/service/controller_service.go
  • e2e/test/e2e_test.go
  • protocol/proto/jumpstarter/client/v1/client.proto
  • python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/__init__.py
  • python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/rotate.py
  • python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/rotate_test.py
  • python/packages/jumpstarter-cli/jumpstarter_cli/auth.py
  • python/packages/jumpstarter-cli/jumpstarter_cli/auth_test.py
  • python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients.py
  • python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/rotate_test.py
  • python/packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2.py
  • python/packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2.pyi
  • python/packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2_grpc.py
  • python/packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2_grpc.pyi
  • python/packages/jumpstarter/jumpstarter/client/grpc.py
  • python/packages/jumpstarter/jumpstarter/config/client.py
✅ Files skipped from review due to trivial changes (1)
  • python/packages/jumpstarter-protocol/jumpstarter_protocol/jumpstarter/client/v1/client_pb2.py

Comment on lines +172 to +183
await self.core_api.delete_namespaced_secret(secret_name, self.namespace)

count = 0
while count < CREATE_CLIENT_COUNT:
try:
secret = await self.core_api.read_namespaced_secret(secret_name, self.namespace)
if secret.data and "token" in secret.data:
return base64.b64decode(secret.data["token"]).decode("utf8")
except ApiException as e:
if e.status != 404:
raise
count += 1

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Show the referenced code with line numbers
sed -n '130,240p' python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients.py | nl -ba | sed -n '1,130p'

# 2) Find the function containing this loop and print a wider window for context
python - <<'PY'
import re, pathlib
path = pathlib.Path("python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients.py")
txt = path.read_text()
# Find likely function def near the referenced line number
lines = txt.splitlines()
for i in range(len(lines)):
    if i+1 in range(160, 210) and re.match(r'\s*async\s+def\s+', lines[i-1] if i>0 else ""):
        pass
# Just print all async defs around the region
for m in re.finditer(r'^(async\s+def\s+.*|def\s+.*)\s*:\s*$', txt, re.M):
    start = txt[:m.start()].count("\n")+1
    if 140 <= start <= 230:
        print("\n=== Function starting at line", start, "===\n", m.group(0), "\n")
PY

# 3) Search for CREATE_CLIENT_COUNT / CREATE_CLIENT_DELAY usage and any secret rotation logic
rg -n "CREATE_CLIENT_COUNT|CREATE_CLIENT_DELAY|delete_namespaced_secret|read_namespaced_secret|rotate|stale token" python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients.py python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes -S

Repository: jumpstarter-dev/jumpstarter

Length of output: 114


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients.py"

# 1) Print the referenced region with line numbers (avoid nl)
sed -n '130,240p' "$FILE" | cat -n

# 2) Print a wider window to capture surrounding logic (token rotation / creation)
sed -n '1,260p' "$FILE" | tail -n +110 | cat -n

# 3) Show exact definitions/uses of the retry constants and the secret read/delete loop area
rg -n "CREATE_CLIENT_COUNT|CREATE_CLIENT_DELAY|delete_namespaced_secret|read_namespaced_secret" "$FILE" -S

# 4) Look for any related rotation/secret update helpers in this package
rg -n "secret.*token|token.*secret|rotation|rotate" python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes -S

Repository: jumpstarter-dev/jumpstarter

Length of output: 12782


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/rotate_test.py"

# Show the whole test file (it's small enough; otherwise slice)
wc -l "$FILE"
sed -n '1,220p' "$FILE" | cat -n

Repository: jumpstarter-dev/jumpstarter

Length of output: 5809


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients.py"

# show constants and rotate_client_token body precisely
sed -n '130,220p' "$FILE" | cat -n

# find if any other code handles stale token / wait for deletion elsewhere
rg -n "rotate_client_token|delete_namespaced_secret\\(|read_namespaced_secret\\(|observed_not_found" python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes -S

Repository: jumpstarter-dev/jumpstarter

Length of output: 5617


Avoid returning the pre-delete (“stale”) token in rotate_client_token

rotate_client_token() deletes the Secret and then immediately polls read_namespaced_secret(), returning the first Secret that contains "token". Because Kubernetes Secret deletion is asynchronous, that first successful read can still observe the old Secret (pre-rotation token). The current tests don’t cover this race. Gate token return until at least one 404 has been observed after deletion.

Proposed fix
         secret_name = client.status.credential.name
         await self.core_api.delete_namespaced_secret(secret_name, self.namespace)

         count = 0
+        observed_not_found = False
         while count < CREATE_CLIENT_COUNT:
             try:
                 secret = await self.core_api.read_namespaced_secret(secret_name, self.namespace)
-                if secret.data and "token" in secret.data:
+                if observed_not_found and secret.data and "token" in secret.data:
                     return base64.b64decode(secret.data["token"]).decode("utf8")
             except ApiException as e:
-                if e.status != 404:
+                if e.status == 404:
+                    observed_not_found = True
+                else:
                     raise
             count += 1
             await asyncio.sleep(CREATE_CLIENT_DELAY)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients.py`
around lines 172 - 183, In rotate_client_token, after calling
core_api.delete_namespaced_secret, change the polling logic around
core_api.read_namespaced_secret so that you do not accept the first successful
read as the new token; instead require that at least one ApiException with
status 404 has been observed after deletion before returning a Secret.token.
Update the loop in rotate_client_token (which currently uses
count/CREATE_CLIENT_COUNT and catches ApiException) to track a boolean like
sawNotFound that flips true when e.status == 404, and only decode/return
base64.b64decode(secret.data["token"]).decode("utf8") if sawNotFound is true and
secret.data contains "token"; still re-raise non-404 ApiExceptions and keep the
existing timeout/count behavior.

@mangelajo
mangelajo self-requested a review May 21, 2026 08:45

@mangelajo mangelajo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

look at the remaining @raballew comment, I think it could be stale

@mangelajo
mangelajo merged commit 28e4a59 into main May 22, 2026
33 checks passed
@bennyz
bennyz deleted the internal-secret-rotate branch June 2, 2026 06:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants