Tutorial: Build a runner admission controller
This tutorial guides you through building a runner admission controller that enforces custom policies for CI/CD job execution. You’ll create a controller in Go that connects to the job router and implements an image allowlist policy.
The code examples in this tutorial are adapted from the runner-controller-example repository, which provides a complete reference implementation you can use as a starting point.
By the end of this tutorial, you’ll have a working admission controller that:
- Connects to the job router using gRPC
- Registers itself with GitLab
- Receives job admission requests
- Evaluates jobs against a custom policy
- Returns admission decisions
To build a runner admission controller:
- Create a runner controller in GitLab
- Scope the runner controller
- Create a runner controller token
- Set up your Go project
- Generate client code from protobuf definitions
- Implement authentication
- Implement agent registration
- Implement the admission loop
- Implement an admission policy
- Test with dry run state
- Enable in production
Before you begin
Make sure you have:
- GitLab Self-Managed or GitLab Dedicated with Ultimate tier
- Administrator access to your GitLab instance
- One of the following to interact with the GitLab API:
- GitLab CLI (
glab) 1.85.0 or later, authenticated withglab auth login curlor another HTTP client
- GitLab CLI (
- Go 1.21 or later installed
- The
bufCLI installed for generating Protobuf code - The following feature flags enabled on your GitLab instance:
job_routerjob_router_admission_control
- GitLab Runner 18.9 or later with
FF_USE_JOB_ROUTERenvironment variable set totrue.
Create a runner controller in GitLab
Use the runner controllers API to create a runner controller.
Start with dry_run state to validate your controller behavior before enabling enforcement:
glab runner-controller create --description "Image allowlist controller" --state dry_runcurl --request POST \
--header "PRIVATE-TOKEN: <your_access_token>" \
--header "Content-Type: application/json" \
--data '{"description": "Image allowlist controller", "state": "dry_run"}' \
--url "https://gitlab.example.com/api/v4/runner_controllers"Save the returned id for the next step.
Scope the runner controller
Runner controllers must be scoped to receive admission requests. Without a scope, your controller remains inactive even when enabled.
For this tutorial, scope the controller to all runners in the instance:
glab runner-controller scope create <controller_id> --instancecurl --request POST \
--header "PRIVATE-TOKEN: <your_access_token>" \
--url "https://gitlab.example.com/api/v4/runner_controllers/<controller_id>/scopes/instance"Alternatively, you can scope the controller to specific runners using the Runner controllers API. Use runner-level scoping when you want the controller to evaluate jobs only for certain runners.
Create a runner controller token
Create a token for your runner controller to authenticate with the job router:
glab runner-controller token create <controller_id> --description "Production token"curl --request POST \
--header "PRIVATE-TOKEN: <your_access_token>" \
--header "Content-Type: application/json" \
--data '{"description": "Production token"}' \
--url "https://gitlab.example.com/api/v4/runner_controllers/<controller_id>/tokens"Save the returned token value securely. The token is only displayed once.
Set up your Go project
Create a new Go project:
mkdir runner-admission-controller
cd runner-admission-controller
go mod init example.com/runner-admission-controllerGenerate client code from protobuf definitions
You need to generate gRPC client code from the Protobuf definitions in the GitLab Agent for Kubernetes repository. You can use any method you prefer, including:
- Vendoring the
.protofiles manually and usingprotocdirectly. - Using
bufto fetch and generate code automatically.
For details on the Protobuf definitions, see generating client code in the runner controller specification.
This tutorial uses buf. Create buf.gen.yaml:
version: v2
managed:
enabled: true
disable:
- module: buf.build/bufbuild/protovalidate
override:
- file_option: go_package
value: internal/rpc
inputs:
- git_repo: https://gitlab.com/gitlab-org/cluster-integration/gitlab-agent.git
branch: master
plugins:
- local: ["go", "run", "google.golang.org/protobuf/cmd/[email protected]"]
out: .
- local: ["go", "run", "google.golang.org/grpc/cmd/[email protected]"]
out: .Generate the code:
buf generateThis creates the gRPC client code in internal/rpc/.
Implement authentication
Runner controllers authenticate with the job router using gRPC metadata headers. For specification details, see Authentication in the runner controller specification.
Create a credentials provider that includes the required headers:
type tokenCredentials struct {
token string
}
func (t *tokenCredentials) GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error) {
return map[string]string{
"authorization": "Bearer " + t.token,
"gitlab-agent-type": "runnerc",
}, nil
}
func (t *tokenCredentials) RequireTransportSecurity() bool {
return true
}Use the following code to create the gRPC connection:
conn, err := grpc.NewClient(kasAddress,
grpc.WithTransportCredentials(credentials.NewTLS(nil)),
grpc.WithPerRPCCredentials(&tokenCredentials{token: agentToken}),
)Implement agent registration
Register your controller with the job router for presence tracking and monitoring. Re-register periodically (recommended: every 3 minutes) to maintain presence. For specification details, see AgentRegistrar in the runner controller specification.
func registerAgent(ctx context.Context, conn *grpc.ClientConn, instanceID int64) error {
client := rpc.NewAgentRegistrarClient(conn)
_, err := client.Register(ctx, &rpc.RegisterRequest{
Meta: &rpc.Meta{
Version: "1.0.0",
GitRef: "main",
Architecture: runtime.GOARCH,
},
InstanceId: instanceID,
})
return err
}Implement the admission loop
The admission loop receives job details from the job router and sends decisions. For specification details, see RunnerControllerService and Protocol Flow in the runner controller specification.
func handleAdmissionRequest(ctx context.Context, client rpc.RunnerControllerServiceClient) error {
admissionCtx, cancel := context.WithCancel(ctx)
defer cancel()
stream, err := client.AdmitJob(admissionCtx)
if err != nil {
return err
}
// Wait for admission request
req, err := stream.Recv()
if err != nil {
return err
}
// Evaluate the job (implement your policy here)
admitted, reason := evaluateJob(req)
// Send decision
var resp *rpc.AdmitJobResponse
if admitted {
resp = &rpc.AdmitJobResponse{
AdmissionResponse: &rpc.AdmitJobResponse_Admitted{Admitted: &rpc.Admitted{}},
}
} else {
resp = &rpc.AdmitJobResponse{
AdmissionResponse: &rpc.AdmitJobResponse_Rejected{
Rejected: &rpc.Rejected{Reason: reason},
},
}
}
if err := stream.Send(resp); err != nil {
return err
}
_ = stream.CloseSend()
var x any
err = stream.RecvMsg(x) // consume EOF
if err != io.EOF {
return err
}
return nil
}Implement an admission policy
Implement your custom policy logic. This example rejects images with the :latest tag:
func evaluateJob(req *rpc.AdmitJobRequest) (admitted bool, reason string) {
imageName := req.GetImage().GetName()
// Reject :latest tags
if strings.HasSuffix(imageName, ":latest") {
return false, "images with :latest tag are not allowed"
}
// Check allowlist
allowed := []string{"alpine", "ubuntu", "golang", "ruby", "node", "python"}
for _, prefix := range allowed {
if strings.HasPrefix(imageName, prefix) {
return true, ""
}
}
return false, fmt.Sprintf("image %s is not in the approved list", imageName)
}Test with dry run state
With your controller running and in dry_run state, trigger a CI/CD pipeline.
Check your controller logs to verify it receives admission requests.
The job router logs decisions but does not enforce them for controllers in dry run state.
This action allows you to validate behavior and de-risk your deployment before you enable enforcement.
Enable in production
After validating your controller behavior in dry_run state, update to enabled state:
glab runner-controller update <controller_id> --state enabledcurl --request PUT \
--header "PRIVATE-TOKEN: <your_access_token>" \
--header "Content-Type: application/json" \
--data '{"state": "enabled"}' \
--url "https://gitlab.example.com/api/v4/runner_controllers/<controller_id>"Now your admission decisions affect job execution.
Hosting the runner controller
The runner controller hosting is up to you depending on the scale of the GitLab instance and the load of jobs that are affected by admission control. The only requirement is that the GitLab instance is reachable by the runner controller, because that is where it connects to.
Next steps
- Review the complete example implementation.
- Read the runner controller specification for protocol details.
- Explore using Open Policy Agent (OPA) for more complex policies.