-
Notifications
You must be signed in to change notification settings - Fork 1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Major refactoring & implement standalone mode #12
Conversation
Overhaul the code from a PoC to a semi-productive state. Major changes done to the Processors, mainly the UpdateWatchConfig handler, which provisions k8s managers. Simplify large parts of the codebase. Handle context cancellation propagation properly. Ensure resource cleanup on graceful shutdown - do not leave hanging goroutines.
cmd/remote-work-processor/main.go
Outdated
for connAttempts < maxRetries { | ||
select { | ||
case <-rootCtx.Done(): | ||
break |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Are you sure that this break is not applicable only for select
statement?
cmd/remote-work-processor/main.go
Outdated
if res.Err != nil { | ||
log.Fatalf("Error occurred while processing operation: %v\n", err) | ||
} | ||
isInStandaloneMode, _ := strconv.ParseBool(flag.Lookup(standaloneModeOpt).Value.String()) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What will happen in case standalone-mode
flag is not passed as boolean, e.g unintentionally? Shouldn't we need to handle the possible error?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The flag
package handles parsing errors on the flags. If something that is not a boolean is provided for --standalone-mode
it will fail with a parse error.
Fix comments Exit Pod with status 1 in case the Manager can't be started (in K8s mode) Remove cahing of auth header in HTTP executor Improvement of error messages
ef30518
codes, err2 := parseSuccessResponseCodes(successResponseCodes...) | ||
if err2 != nil { | ||
return false, err2 | ||
func isSuccessfulResponseCode(statusCode int, successResponseCodes ...string) (bool, error) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What's our reason for having the successResponseCodes
as string? From what I can see, we always try to convert them to integers. Any reason why we can't just do the conversion at the top-most location where they are first inputed and then only pass them as integer slices?
Looking at HttpRequestParameters
we already have non-string fields there, so why can't we do the whole string to integer there? Would also mean we get an error when we are building the request parameters and not when we are all the way at checking the response.
Maybe I'm missing something?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
From AutoPi, the success codes are interpreted as strings because they can be "2xx", not just "200", "404", etc.
h.fetcher = NewOAuthTokenFetcher( | ||
withExecutor(executor), | ||
withTokenUrl(tokenUrl), | ||
withRequestBody(requestBody), | ||
withCertificateAuthentication(h.certAuthentication), | ||
withAuthHeader(h.authHeader), | ||
) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Are these with*
arguments all optional? Can we make an OAuth TokenFetcher without an URL for instance? Feels weird to me to have a builder pattern where non-optional fields are in fact optional (compilation wise) as any errors would be discovered on runtime rather than on the much more preferred compile time.
Can we perhaps consider making the always required parameters as concrete parameters of the function and the optional ones (I think f.certAuthentication might be?) to be left as the optionals at the end of the function?
As an example the NewOAuthorizationHeaderGenerator
seems to be written in such a way.
c.selector.LabelSelector.Matches(labels.Set(o.GetLabels())) && | ||
c.selector.FieldSelector.Matches(o) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Potential nil pointer access if withSelector
is never called and c.selector
is left as nil. This currently isn't as issue as the only place it is used will always call it, but it still leaves a potential landmine in any future code refactoring/modification.
func (c *ControllerBuilder) WithReconcilicationPeriodInMinutes(period int32) *ControllerBuilder { | ||
c.reconciliationPeriodInMinutes = period | ||
return c | ||
} | ||
|
||
func (c *ControllerBuilder) WithSelector(selector *selector.Selector) *ControllerBuilder { | ||
c.selector = selector | ||
return c | ||
} | ||
|
||
func (c *ControllerBuilder) ManagedBy(manager *Manager) *ControllerBuilder { | ||
c.manager = manager | ||
return c | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Again, are these fields really optional or are they always required in order for the code to work?
If they are always required, I dislike the idea of having them as optional parameters just so we save some horizontal space (in return for using more vertical one) and introducing potential future runtime errors.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What would you say to adding a validation in the build()
method of all builders that checks for the required fields, and if any aren't set to return an error? This would be semantically similar to the Immutables library for Java.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It would be an improvement, but it would still be a runtime check rather than a compile time error. My main gripe with required "optionals" is that you have no compile time information that you forgot to set a field or not. If you're worried about the "constructor" function having too much horizontal space taken, you can always format it as:
func example(
firstRequired string,
secondRequired int,
thirdRequired byte,
optionals ...Option
) {
<code>
}
func (b *ManagerBuilder) SetGrpcClient(client *grpc.RemoteWorkProcessorGrpcClient) *ManagerBuilder { | ||
b.grpcClient = client | ||
return b | ||
} | ||
|
||
func (b *ManagerBuilder) BuildDynamicClient(config *rest.Config) *ManagerBuilder { | ||
dc, err := dynamic.NewDynamicClient(config) | ||
if err != nil { | ||
log.Panicln("Failed to create dynamic client:", err) | ||
} | ||
b.dynamicClient = dc | ||
return b | ||
} | ||
|
||
func (b *ManagerBuilder) BuildInternalManager(config *rest.Config, scheme *runtime.Scheme) *ManagerBuilder { | ||
t := time.Duration(0) | ||
options := manager.Options{ | ||
Scheme: scheme, | ||
GracefulShutdownTimeout: &t, | ||
WebhookServer: nil, | ||
LeaderElection: false, | ||
HealthProbeBindAddress: "localhost:8811", | ||
MetricsBindAddress: "0", | ||
} | ||
|
||
mgr, err := ctrl.NewManager(config, options) | ||
if err != nil { | ||
log.Panicln("Failed to create manager:", err) | ||
} | ||
|
||
// these can fail only if the manager has been started prior to calling them | ||
mgr.AddHealthzCheck("healthz", healthz.Ping) | ||
mgr.AddReadyzCheck("readyz", healthz.Ping) | ||
b.delegate = mgr | ||
return b | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looking at these, the only reason they are a struct method instead of a separate function is so they can assign the result in the end. Any reason why they are optionals again and are they really optional in our case? Any reason they can't be separate functions that have their results passed as arguments to the actual build function so that we can avoid having optional parameters that are actually always needed?
@@ -60,6 +60,9 @@ func (b *ManagerBuilder) BuildInternalManager(config *rest.Config, scheme *runti | |||
} | |||
|
|||
func (b *ManagerBuilder) Build() Manager { | |||
if b.delegate == nil || b.dynamicClient == nil || b.grpcClient == nil { | |||
panic("Manager is missing required parameters") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe we can have the build return a (*Manager, error) so that error is handled by the upper function WatchResources
which already has a flow handling exiting the app in case of error?
Overhaul the code from a PoC to a semi-productive state.
Major changes done to the Processors, mainly the UpdateWatchConfig handler, which provisions k8s managers.
Simplify large parts of the codebase.
Handle context cancellation propagation properly.
Ensure resource cleanup on graceful shutdown - do not leave hanging goroutines.
Refactoring main points:
Details about context usage
Context flow between objects:
The root context traps the
SIGINT
andSIGTERM
signals, so it can be used for graceful shutdown.The gRPC client then wraps it with its own
cancel()
function, so it can stop onSend()
orRecv()
errors.Additionally, it adds the Session ID and Binary Version header values to the context being sent to the AutoPi.
The client has a heartbeat in which it
select
's on the contextDone()
channel and a 30-second timer.The processors accept the root context as a parameter to their
Process
method.Currently, the context is only used in the
UpdateWatchConfig
handler, as it's needed for proper flow control of the K8s Manager(s) it spawns.The processor builds the watched resources controllers, wraps the root context with its own
cancel()
function and starts the Manager with the wrapped context.The internal cancellation will happen when the manager is stopped - in the case where a newer watch configuration is received and the manager needs to be recreated.
The manager will then block until either the context is cancelled or an error occurs within its controllers.