Skip to content

feat: obtain the Consul ACL token by a Kubernetes auth method - #192

Open
TaurMorchant wants to merge 20 commits into
mainfrom
feat/consul-k8s-login
Open

feat: obtain the Consul ACL token by a Kubernetes auth method#192
TaurMorchant wants to merge 20 commits into
mainfrom
feat/consul-k8s-login

Conversation

@TaurMorchant

@TaurMorchant TaurMorchant commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

What this adds

A second way to obtain the Consul ACL token: the pod exchanges its projected Kubernetes service account token at a
Consul auth method of type kubernetes. The M2M way keeps working for the whole migration and nothing is removed here.

The two halves of the migration ship separately. A service picks up the new library whenever it releases, and the
kubernetes auth method appears in Consul whenever the platform registers it, in either order. A pod that starts before
Consul is ready therefore begins on the M2M way and moves over by itself later, without a restart.

How the token is obtained

One property picks the way:

Mode Behavior
kubernetes-with-m2m-fallback (default) Tries the kubernetes way, falls back to M2M if it fails, and retries the kubernetes way later
kubernetes Only the kubernetes way, with no probe and no fallback
m2m Only the M2M way, exactly as before this change

An unknown value fails the startup. Both stacks read the mode at runtime, so switching it needs no rebuild.

A failed login is a different matter, and the two stacks part here. The Spring ConfigData phase never ends on one: it
logs the failure and the application starts without an ACL token, in every mode and whatever the failure was. The
TokenStorage bean is stricter, on both stacks — a login failure its retries do not fix ends the startup.

Behavior in the main scenarios

The auth method already exists in Consul. The first login goes the kubernetes way and succeeds. No fallback happens,
and the pod logs one INFO record naming the way it uses.

The auth method is not registered yet. The kubernetes login fails, and the pod serves tokens through the M2M way. It
logs the reason, the response code, and a truncated body in a single INFO record. That record marks the decision and
does not repeat on retries; the login attempts themselves keep logging one record each, as they always did.

The auth method appears later. Every scheduled relogin retries the kubernetes way, no more often than
fallback-recheck-interval. The first success switches the pod over and logs a second INFO record. The switch is
final: the pod never returns to the M2M way, and a later kubernetes failure is a plain failure.

Because the recheck rides on the relogin, fallback-recheck-interval is a lower bound and not a period. The relogin
runs at 80% of the remaining lifetime, so with a MaxTokenTTL of 24 hours a pod retries about every 19 hours whatever
the interval says. A fleet migrates on the MaxTokenTTL clock; lowering MaxTokenTTL on the auth method is what makes
it migrate sooner.

The token expires. The pod relogins at 80% of the token's remaining lifetime. The period follows the token the pod
holds right now rather than the first one, because the two auth methods can carry different MaxTokenTTL. A failed
login is retried with an exponential delay and jitter, and the schedule survives a failure instead of stopping.

The auth method has no MaxTokenTTL. Consul issues a token that never expires, so no relogin is scheduled, and no
recheck happens either. Such a pod keeps the way it picked at startup.

Spring starts in two phases. The ConfigData phase logs in once and hands the token to the TokenStorage bean. That
bean reads /v1/acl/token/self, sees which auth method issued the token, and continues with that way instead of probing
a second time.

Configuration

Four properties, under spring.cloud.consul.config.login. and quarkus.consul-source-config.login.:

Property Default Used
mode kubernetes-with-m2m-fallback always
auth-method applications-k8s-m2m kubernetes way only
audience netcracker kubernetes way only
fallback-recheck-interval 5 hours fallback mode only

Durations take the format of their stack: 5h in Spring, PT5H in Quarkus.

The defaults match what the platform registers, so a service normally sets nothing. They live in the options builder,
which also serves callers that build the options directly instead of going through properties.

Set these properties in the service's own configuration: application.yaml, an environment variable, or a system
property. They cannot come from Consul, because reading Consul needs the token they produce.

In kubernetes mode the Spring side needs neither M2MManager nor the namespace. The Quarkus producer still reads
cloud.microservice.namespace in every mode, even though the namespace never reaches Consul.

spring.cloud.consul.config.m2m.enabled=false and quarkus.consul-source-config.m2m.enabled=false still turn the whole
exchange off, and then none of the four are used.

Entry points

All four are wired: the Spring ConfigData phase, the RestTemplate and WebClient autoconfigurations, and the Quarkus CDI
producer. In kubernetes mode the ConfigData phase no longer reaches into the bootstrap registry for M2MManager. In
the fallback mode it resolves M2MManager only when a bearer token is actually needed.

Built to be removed

The M2M way goes away in a later major version, and this change is shaped so that removal is a deletion rather than a
rewrite. Everything specific to the M2M way sits behind one seam and carries M2M in its name: the credentials, the
fallback provider, and the mode enum are separate files that get deleted whole. Below the seam the code sees a single
way of obtaining a token and needs no edits. What remains is dropping the deprecated login method, the client
constructors that take an M2M token supplier, and the two builder inputs the M2M way needs.

Compatibility

Nothing breaks here. ConsulClient gains a login method taking credentials, with a default implementation that
delegates to the deprecated one, so an outside implementation keeps compiling and working. The options builder gains
methods. Existing methods, bean names, and property names are untouched.

For the release notes:

  • the default mode probes the kubernetes way, which costs an unprepared environment a few requests per pod start and
    one log record;
  • the relogin moves from a fixed five minutes before expiry to 80% of the remaining lifetime, and its period now follows
    the current token;
  • a pod can change its way while running, not only at startup;
  • the options builder throws IllegalArgumentException instead of an unmessaged NullPointerException;
  • the Spring ConfigData phase now logs and survives every login failure. Before, it survived an IOException and ended
    the startup on a malformed answer from Consul.

Open

  • No integration test against a real Consul: that needs a Kubernetes cluster on CI.

…L token

Introduce ConsulLoginCredentials as the seam between the login protocol and
the way the bearer token is obtained, and build the core of the new way on
top of it:

- ConsulLoginCredentials with M2MLoginCredentials and KubernetesLoginCredentials
- ConsulClient.login(ConsulLoginCredentials) added with a default implementation;
  login(String) is deprecated for removal
- ConsulOkHttpClient lets a transport IOException out instead of wrapping it,
  ConsulRestClient wraps MicroserviceRestClientException into IOException
- ConsulLogin as the single-operation interface, TokenProvider implements it
- SelfTokenReader takes reading an already issued token out of TokenProvider
- TokenUpdater works through ConsulLogin, schedules a relogin at 80% of the
  remaining lifetime and pauses between retries
- ProbingConsulLogin probes the new way, falls back to the old one on any
  failure and sticks to the choice until the pod restarts
- ConsulLoginMode plus mode, authMethod and audience inputs in CreateOptions,
  with per-mode validation
- TokenStorageFactory.from builds the chain in one place

The auth method default is a placeholder until infrastructure confirms the
real name.
…ints

Wire the four places that build CreateOptions to the new login mode, auth
method and audience, and document the properties:

- Spring property names live in ConsulM2MConfigDataLocationResolver; the
  RestTemplate and WebClient autoconfigurations reference them
- values are read through Binder, which converts the lowercase mode written
  in configuration regardless of the environment conversion service
- the ConfigData phase builds the chain through TokenStorageFactory.from and
  resolves M2MManager from the bootstrap registry only when the bearer token
  is actually needed, so the kubernetes mode never touches the registry
- the Quarkus producer takes the three values as @ConfigProperty, so the mode
  can be switched without rebuilding the application
- the WebClient module gets src/test and its first tests
- the properties are documented for both stacks

The Quarkus tests do not run yet: cloud-core-quarkus-bom-internal pins
core-rest-libraries to the released 7.4.0, so the extension compiles against
the published consul-config-provider-common instead of the reactor.
- ConsulLoginCredentials exposes getAuthMethod and getBearerToken, matching
  the other getters in the module
- the fallback mode is named KUBERNETES_WITH_M2M_FALLBACK instead of AUTO, so
  the property value says what the mode does
- retries in TokenUpdater and in the probe are built on failsafe
  (LoginRetryPolicies) instead of a hand-rolled loop around Thread.sleep, and
  gain exponential backoff with jitter; the library was already on the
  classpath through k8s-utils
- the delay fraction is one constant, and the method computing it is named
  after what it returns
- the three Spring properties are bound into ConsulLoginProperties, shared by
  the ConfigData phase and both autoconfigurations; the duplicated name
  constants are gone
- ConsulRestClient says why a transport failure is wrapped into IOException
- the m2m mode gets coverage in the entry points, where only kubernetes and
  the default were checked before

@cf_ignore covers the deferred russian TODO in ProbingConsulLogin, kept until
the naming pass agreed with the owner.
ConsulLogin read as a verb phrase and told the reader nothing about what the
type holds:

- ConsulLogin becomes ConsulTokenProvider, and perform becomes getToken, the
  name already used for a token fetch that goes over the network in
  M2MManager and KubernetesAudienceToken
- TokenProvider becomes LoginTokenProvider: it obtains a token by performing
  a Consul login with one set of credentials
- ProbingConsulLogin becomes KubernetesWithM2MFallbackTokenProvider, matching
  the name of the mode it serves, and its fields are named after the two ways
  they hold, so the class no longer mixes generic field names with hardcoded
  log constants

The type stays deleted whole once the m2m way goes away, so a generic
fallback decorator would carry generality nothing uses.
Javadoc for the types and methods whose contract is not visible from the
signature: what IOException means for a caller, why the delay is a share of
the remaining lifetime, why the fallback choice sticks until the pod
restarts, why the defaults live in the builder, and why the ConfigData phase
binds the mode instead of injecting it. Getters, constructors and the plain
value types are left alone.

Also folds the review note on the retry supplier: the custom CheckedFunction
had lost its argument and became a supplier, so failsafe's own
CheckedSupplier replaces it.
A service can be on the new library and the new projected tokens while Consul
has not registered the auth method yet. Such a pod fell back to m2m and stayed
there until someone restarted it. Now it moves over on its own once Consul is
ready:

- the fallback is temporary and the switch is permanent, so the state is a
  ratchet: probe, fall back, recheck, kubernetes for good
- the recheck rides on the scheduled relogin instead of a timer of its own,
  throttled by the new fallback-recheck-interval property, five hours by
  default
- the relogin schedule now follows the expiration of the token just received
  rather than of the first one: the two auth methods carry different
  MaxTokenTTL, and a fixed period would leave the new token unrefreshed after
  the switch
- a second INFO record marks the switch; failed rechecks stay silent so an
  unmigrated fleet does not log on every relogin

Rescheduling outlives the migration and fixes an ordinary operation on its
own: changing MaxTokenTTL on an auth method without restarting the pods.

Without MaxTokenTTL nothing is scheduled and nothing is rechecked, which is
acceptable: such a token never expires and needs no new one.
@TaurMorchant
TaurMorchant requested a review from lis0x90 as a code owner August 27, 2026 12:58
@github-actions github-actions Bot added documentation Improvements or additions to documentation enhancement New feature or request refactor labels Aug 27, 2026
… stack

ConsulRestClient logged the login at debug while ConsulOkHttpClient logged it
at info, so a Quarkus pod named its auth method and a Spring pod did not. Pods
that reuse a token obtained in the ConfigData phase never log in at all, so
raising the level alone would still leave them silent; SelfTokenReader now
names the auth method taken from the answer of Consul, which is the method
that actually issued the token in use.

Consul omits AuthMethod for a token no login produced, and that case reads as
unknown rather than failing the parse.
@github-actions github-actions Bot added the bug Something isn't working label Aug 28, 2026
A failed relogin repeated the previous delay, so on a short-lived token
the retry landed well past the expiration. Every delay is now measured
against the expiration of the token the pod holds right now.

The spring stack built a second provider for the TokenStorage bean and
lost the way the ConfigData phase had already picked, so one transient
failure sent a migrated pod back to m2m. Reading the existing token moved
onto ConsulTokenProvider, which recovers the way from its AuthMethod.
…Error

The scheduled task caught Exception, so an Error walked out of the lambda,
landed in a Future nobody reads, and the task never ran again. The pod then
kept a token that eventually expired and every Consul read started failing,
with nothing in the log to say why. The SPI behind the kubernetes bearer token
throws exactly such an Error when no TokenSource provider is on the classpath.

The task now catches Throwable, logs it and rearms. That is a deliberate
exception to the rule of the module that lets an Error through: the rule earns
its keep where a caller can see the failure, and on a pool thread nobody can.

Also from review: probe becomes probeKubernetesWay and says why it unwraps the
failsafe failure back into an IOException; the SelfTokenReader and
Token.getAuthMethod comments catch up with the code, the latter having claimed
that a login answer carries no auth method when it does; and both READMEs say
the m2m mode does not use the auth method and the audience rather than not
reading them, which it does through the builder defaults.
Infrastructure named the kubernetes auth method, so the default stops being a
placeholder and a service no longer has to set login.auth-method to migrate.
The login retries compile against net.jodah:failsafe, which reached the
module only through k8s-utils, and TokenUpdater reached commons-lang3 the
same way for a single StringUtils.isEmpty call. Both would break on the next
k8s-utils upgrade, and the repository is already split between net.jodah and
dev.failsafe.

failsafe is now declared, pinned to the 2.4.4 the other modules use, and the
emptiness check goes back to the plain comparison it replaced, so
commons-lang3 is not needed at all. mvn dependency:analyze no longer reports
either as used-undeclared.
… hit

The phase caught IOException only, so the failure type decided the outcome: a
403 from Consul was logged and the application started without an ACL token,
while an answer without a SecretID threw PathNotFoundException past the catch
and ended the startup. Same cause, two behaviors, neither of them chosen.

The catch now covers Exception, so every login failure ends the phase the same
way in every mode, and the TokenStorage bean is left to obtain the token.

SelfTokenReader gains the empty-body check LoginTokenProvider already had; a
null body used to reach JsonPath and come out as a NullPointerException
instead of the IOException the callers retry on.
Three duplications, all of them mechanical:

- both consul clients built the login request twice, once for the deprecated
  login(String) and once for login(credentials). The building moves into a
  private login(authMethod, bearerToken); the two public methods keep the
  exception handling that genuinely differs between them
- the four login inputs were mapped onto CreateOptions in three places. They
  move into ConsulLoginProperties.toOptionsBuilder, which the ConfigData phase
  and both autoconfigurations now start from
- the two autoconfiguration tests were the same file twice. The mapping is
  covered once in ConsulLoginPropertiesTest, and each module keeps only the
  wiring it owns

Also from the review:

- TokenStorageFactory.from lists KUBERNETES_WITH_M2M_FALLBACK explicitly and
  throws in the default branch, so a new mode without a branch fails loudly
  instead of silently becoming the fallback
- isKubernetesConfirmed drops volatile: both fields are read and written under
  the instance lock only, and one of them claiming otherwise misled the reader
- the relogin delay is computed once per schedule rather than twice, so the
  logged value is the one the executor gets
- unknownModeIsNotCreated asserted Enum.valueOf and tested the JDK. It becomes
  everyModeBuildsAProvider, which walks ConsulLoginMode.values() and fails when
  a new constant has no branch
- the quarkus side gains the binding test for fallback-recheck-interval, which
  only mode had
- TestClock moves out of the middle of the test methods
The recheck landed after most of this text was written, and the text still
described the behavior it replaced:

- ConsulLoginMode claimed a pod keeps one way for its whole life, and the
  fallback constant claimed it tries the kubernetes way once and stays on m2m
  for the rest of that life. Both stopped being true when the recheck arrived
- the m2m constant said the auth method and the audience are not read. They
  are: the builder defaults them. They are not used
- ConsulTokenProvider promised IOException on a transport failure of the self
  read, while SelfTokenReader documents the opposite, because the client does
  not wrap that one
- getToken described the probe as happening after a recheck interval, leaving
  out the first call
- LoginRetryPolicies said anything other than IOException means the input is
  wrong. A missing projected token is the environment, not the input

The READMEs gain what an operator has to know and could not read anywhere:

- fallback-recheck-interval is the lower bound, not the period. The recheck
  waits for the next relogin, which runs at 80% of MaxTokenTTL, so a fleet
  migrates on the MaxTokenTTL clock
- a failed login ends differently on the two stacks: the spring ConfigData
  phase survives it and the TokenStorage bean does not, and on quarkus the bean
  cannot be produced at all
- the fallback record is one per decision, but every login attempt still logs
  one of its own
- cloud.microservice.namespace stays required on quarkus in the kubernetes
  mode, unlike the namespace on the spring side

Also drops the ratchet metaphor, which named the mechanism after a machine part
instead of describing it.
@TaurMorchant TaurMorchant changed the title feat(consul): obtain the Consul ACL token by a Kubernetes auth method feat: obtain the Consul ACL token by a Kubernetes auth method Sep 2, 2026
…type

Infrastructure is considering an auth method of type jwt instead of type
kubernetes. The login request carries the same two fields either way, so the
README should not pin the way to one type of auth method on the Consul side.
@sonarqubecloud

sonarqubecloud Bot commented Sep 3, 2026

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working documentation Improvements or additions to documentation enhancement New feature or request refactor

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Core: Adapt Consul Property Source to m2m v2

2 participants