infra: add stateless gitea runner scaler

This commit is contained in:
Seth Call 2026-03-13 20:18:23 -05:00
parent 04688742b3
commit 2e1266cede
10 changed files with 1385 additions and 0 deletions

View File

@ -0,0 +1,22 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: runner-scaler
namespace: argocd
finalizers:
- resources-finalizer.argocd.argoproj.io
spec:
project: default
source:
repoURL: 'git@bitbucket.org:jamkazam/video-iac.git'
targetRevision: develop
path: k8s/jam-cloud-infra
directory:
include: 'runner-scaler.yaml'
destination:
server: https://kubernetes.default.svc
namespace: jam-cloud-infra
syncPolicy:
automated:
prune: true
selfHeal: true

View File

@ -0,0 +1,86 @@
apiVersion: v1
kind: Secret
metadata:
name: gitea-external-scaler-secret
namespace: jam-cloud-infra
type: Opaque
stringData:
webhookSecret: f5edc33f5a9ea1b01c6a75883f2f899b526c860d0f5654ae21faa4c1a61c43d5
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: gitea-external-scaler
namespace: jam-cloud-infra
spec:
replicas: 1
selector:
matchLabels:
app: gitea-external-scaler
template:
metadata:
labels:
app: gitea-external-scaler
spec:
imagePullSecrets:
- name: gitea-registry
containers:
- name: scaler
image: git.staging.jamkazam.com/seth/gitea-external-scaler:2.0.1
imagePullPolicy: Always
ports:
- name: grpc
containerPort: 50051
- name: http
containerPort: 8080
env:
- name: REDIS_ADDR
value: redis.jam-cloud-infra.svc.cluster.local:6379
- name: REDIS_DB
value: "1"
- name: WEBHOOK_SECRET
valueFrom:
secretKeyRef:
name: gitea-external-scaler-secret
key: webhookSecret
readinessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 5
periodSeconds: 10
restartPolicy: Always
---
apiVersion: v1
kind: Service
metadata:
name: gitea-external-scaler
namespace: jam-cloud-infra
spec:
selector:
app: gitea-external-scaler
ports:
- name: grpc
port: 50051
targetPort: grpc
- name: http
port: 8080
targetPort: http
---
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: act-runner-scaler
namespace: jam-cloud-infra
spec:
scaleTargetRef:
name: act-runner
minReplicaCount: 0
maxReplicaCount: 5
cooldownPeriod: 300
pollingInterval: 15
triggers:
- type: external
metadata:
scalerAddress: gitea-external-scaler.jam-cloud-infra.svc.cluster.local:50051
labels: ubuntu-latest

View File

@ -0,0 +1,26 @@
FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder
ARG TARGETOS
ARG TARGETARCH
WORKDIR /app
RUN apk add --no-cache protobuf
RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.10
RUN go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.5.1
COPY . .
RUN mkdir -p externalscaler
RUN protoc --go_out=externalscaler --go_opt=paths=source_relative \
--go-grpc_out=externalscaler --go-grpc_opt=paths=source_relative \
externalscaler.proto
RUN go mod tidy
RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-amd64} go build -o scaler main.go
FROM alpine:3.22
WORKDIR /root/
COPY --from=builder /app/scaler .
EXPOSE 50051 8080
CMD ["./scaler"]

View File

@ -0,0 +1,30 @@
Stateless Gitea runner scaler.
Design:
- Gitea emits `workflow_job` webhooks to `POST /webhooks/workflow-job`
- This service verifies the webhook signature and tracks queued jobs in Redis
- KEDA polls the gRPC external scaler API on port `50051`
- The scale target is `Deployment/act-runner`
Why this exists:
- The previous scaler read `gitea.db` from the `gitea-data` SQLite volume
- That coupled scaler scheduling and failover to the same `ReadWriteOnce` PVC as Gitea
- This redesign removes that PVC dependency entirely
Current assumptions:
- Gitea webhook payloads are GitHub-compatible enough to provide:
- `action`
- `workflow_job.id`
- `workflow_job.labels`
- `workflow_job.status`
- The label we currently care about is `ubuntu-latest`
Required wiring before production/staging rollout:
1. Build and publish `git.staging.jamkazam.com/seth/gitea-external-scaler:2.0.1`
2. Set a real `webhookSecret` in `runner-scaler.yaml`
3. Add repo or instance webhooks in Gitea that send `workflow_job` events to:
`http://gitea-external-scaler.jam-cloud-infra.svc.cluster.local:8080/webhooks/workflow-job`
4. Decide whether `act-runner` desired replicas should move to `0` in repo-managed state
Reference:
- Gitea docs recommend the `workflow_job` webhook to automate registration/startup of new runners.

View File

@ -0,0 +1,46 @@
syntax = "proto3";
package externalscaler;
option go_package = ".;externalscaler";
service ExternalScaler {
rpc IsActive(ScaledObjectRef) returns (IsActiveResponse) {}
rpc StreamIsActive(ScaledObjectRef) returns (stream IsActiveResponse) {}
rpc GetMetricSpec(ScaledObjectRef) returns (GetMetricSpecResponse) {}
rpc GetMetrics(GetMetricsRequest) returns (GetMetricsResponse) {}
}
message ScaledObjectRef {
string name = 1;
string namespace = 2;
map<string, string> scalerMetadata = 3;
}
message IsActiveResponse {
bool result = 1;
}
message GetMetricSpecResponse {
repeated MetricSpec metricSpecs = 1;
}
message MetricSpec {
string metricName = 1;
int64 targetSize = 2;
double targetSizeFloat = 3;
}
message GetMetricsRequest {
ScaledObjectRef scaledObjectRef = 1;
string metricName = 2;
}
message GetMetricsResponse {
repeated MetricValue metricValues = 1;
}
message MetricValue {
string metricName = 1;
int64 metricValue = 2;
double metricValueFloat = 3;
}

View File

@ -0,0 +1,498 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.10
// protoc v6.33.4
// source: externalscaler.proto
package externalscaler
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type ScaledObjectRef struct {
state protoimpl.MessageState `protogen:"open.v1"`
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
Namespace string `protobuf:"bytes,2,opt,name=namespace,proto3" json:"namespace,omitempty"`
ScalerMetadata map[string]string `protobuf:"bytes,3,rep,name=scalerMetadata,proto3" json:"scalerMetadata,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ScaledObjectRef) Reset() {
*x = ScaledObjectRef{}
mi := &file_externalscaler_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ScaledObjectRef) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ScaledObjectRef) ProtoMessage() {}
func (x *ScaledObjectRef) ProtoReflect() protoreflect.Message {
mi := &file_externalscaler_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ScaledObjectRef.ProtoReflect.Descriptor instead.
func (*ScaledObjectRef) Descriptor() ([]byte, []int) {
return file_externalscaler_proto_rawDescGZIP(), []int{0}
}
func (x *ScaledObjectRef) GetName() string {
if x != nil {
return x.Name
}
return ""
}
func (x *ScaledObjectRef) GetNamespace() string {
if x != nil {
return x.Namespace
}
return ""
}
func (x *ScaledObjectRef) GetScalerMetadata() map[string]string {
if x != nil {
return x.ScalerMetadata
}
return nil
}
type IsActiveResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Result bool `protobuf:"varint,1,opt,name=result,proto3" json:"result,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *IsActiveResponse) Reset() {
*x = IsActiveResponse{}
mi := &file_externalscaler_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *IsActiveResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*IsActiveResponse) ProtoMessage() {}
func (x *IsActiveResponse) ProtoReflect() protoreflect.Message {
mi := &file_externalscaler_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use IsActiveResponse.ProtoReflect.Descriptor instead.
func (*IsActiveResponse) Descriptor() ([]byte, []int) {
return file_externalscaler_proto_rawDescGZIP(), []int{1}
}
func (x *IsActiveResponse) GetResult() bool {
if x != nil {
return x.Result
}
return false
}
type GetMetricSpecResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
MetricSpecs []*MetricSpec `protobuf:"bytes,1,rep,name=metricSpecs,proto3" json:"metricSpecs,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetMetricSpecResponse) Reset() {
*x = GetMetricSpecResponse{}
mi := &file_externalscaler_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetMetricSpecResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetMetricSpecResponse) ProtoMessage() {}
func (x *GetMetricSpecResponse) ProtoReflect() protoreflect.Message {
mi := &file_externalscaler_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetMetricSpecResponse.ProtoReflect.Descriptor instead.
func (*GetMetricSpecResponse) Descriptor() ([]byte, []int) {
return file_externalscaler_proto_rawDescGZIP(), []int{2}
}
func (x *GetMetricSpecResponse) GetMetricSpecs() []*MetricSpec {
if x != nil {
return x.MetricSpecs
}
return nil
}
type MetricSpec struct {
state protoimpl.MessageState `protogen:"open.v1"`
MetricName string `protobuf:"bytes,1,opt,name=metricName,proto3" json:"metricName,omitempty"`
TargetSize int64 `protobuf:"varint,2,opt,name=targetSize,proto3" json:"targetSize,omitempty"`
TargetSizeFloat float64 `protobuf:"fixed64,3,opt,name=targetSizeFloat,proto3" json:"targetSizeFloat,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *MetricSpec) Reset() {
*x = MetricSpec{}
mi := &file_externalscaler_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *MetricSpec) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*MetricSpec) ProtoMessage() {}
func (x *MetricSpec) ProtoReflect() protoreflect.Message {
mi := &file_externalscaler_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use MetricSpec.ProtoReflect.Descriptor instead.
func (*MetricSpec) Descriptor() ([]byte, []int) {
return file_externalscaler_proto_rawDescGZIP(), []int{3}
}
func (x *MetricSpec) GetMetricName() string {
if x != nil {
return x.MetricName
}
return ""
}
func (x *MetricSpec) GetTargetSize() int64 {
if x != nil {
return x.TargetSize
}
return 0
}
func (x *MetricSpec) GetTargetSizeFloat() float64 {
if x != nil {
return x.TargetSizeFloat
}
return 0
}
type GetMetricsRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
ScaledObjectRef *ScaledObjectRef `protobuf:"bytes,1,opt,name=scaledObjectRef,proto3" json:"scaledObjectRef,omitempty"`
MetricName string `protobuf:"bytes,2,opt,name=metricName,proto3" json:"metricName,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetMetricsRequest) Reset() {
*x = GetMetricsRequest{}
mi := &file_externalscaler_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetMetricsRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetMetricsRequest) ProtoMessage() {}
func (x *GetMetricsRequest) ProtoReflect() protoreflect.Message {
mi := &file_externalscaler_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetMetricsRequest.ProtoReflect.Descriptor instead.
func (*GetMetricsRequest) Descriptor() ([]byte, []int) {
return file_externalscaler_proto_rawDescGZIP(), []int{4}
}
func (x *GetMetricsRequest) GetScaledObjectRef() *ScaledObjectRef {
if x != nil {
return x.ScaledObjectRef
}
return nil
}
func (x *GetMetricsRequest) GetMetricName() string {
if x != nil {
return x.MetricName
}
return ""
}
type GetMetricsResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
MetricValues []*MetricValue `protobuf:"bytes,1,rep,name=metricValues,proto3" json:"metricValues,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetMetricsResponse) Reset() {
*x = GetMetricsResponse{}
mi := &file_externalscaler_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetMetricsResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetMetricsResponse) ProtoMessage() {}
func (x *GetMetricsResponse) ProtoReflect() protoreflect.Message {
mi := &file_externalscaler_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetMetricsResponse.ProtoReflect.Descriptor instead.
func (*GetMetricsResponse) Descriptor() ([]byte, []int) {
return file_externalscaler_proto_rawDescGZIP(), []int{5}
}
func (x *GetMetricsResponse) GetMetricValues() []*MetricValue {
if x != nil {
return x.MetricValues
}
return nil
}
type MetricValue struct {
state protoimpl.MessageState `protogen:"open.v1"`
MetricName string `protobuf:"bytes,1,opt,name=metricName,proto3" json:"metricName,omitempty"`
MetricValue int64 `protobuf:"varint,2,opt,name=metricValue,proto3" json:"metricValue,omitempty"`
MetricValueFloat float64 `protobuf:"fixed64,3,opt,name=metricValueFloat,proto3" json:"metricValueFloat,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *MetricValue) Reset() {
*x = MetricValue{}
mi := &file_externalscaler_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *MetricValue) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*MetricValue) ProtoMessage() {}
func (x *MetricValue) ProtoReflect() protoreflect.Message {
mi := &file_externalscaler_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use MetricValue.ProtoReflect.Descriptor instead.
func (*MetricValue) Descriptor() ([]byte, []int) {
return file_externalscaler_proto_rawDescGZIP(), []int{6}
}
func (x *MetricValue) GetMetricName() string {
if x != nil {
return x.MetricName
}
return ""
}
func (x *MetricValue) GetMetricValue() int64 {
if x != nil {
return x.MetricValue
}
return 0
}
func (x *MetricValue) GetMetricValueFloat() float64 {
if x != nil {
return x.MetricValueFloat
}
return 0
}
var File_externalscaler_proto protoreflect.FileDescriptor
const file_externalscaler_proto_rawDesc = "" +
"\n" +
"\x14externalscaler.proto\x12\x0eexternalscaler\"\xe3\x01\n" +
"\x0fScaledObjectRef\x12\x12\n" +
"\x04name\x18\x01 \x01(\tR\x04name\x12\x1c\n" +
"\tnamespace\x18\x02 \x01(\tR\tnamespace\x12[\n" +
"\x0escalerMetadata\x18\x03 \x03(\v23.externalscaler.ScaledObjectRef.ScalerMetadataEntryR\x0escalerMetadata\x1aA\n" +
"\x13ScalerMetadataEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"*\n" +
"\x10IsActiveResponse\x12\x16\n" +
"\x06result\x18\x01 \x01(\bR\x06result\"U\n" +
"\x15GetMetricSpecResponse\x12<\n" +
"\vmetricSpecs\x18\x01 \x03(\v2\x1a.externalscaler.MetricSpecR\vmetricSpecs\"v\n" +
"\n" +
"MetricSpec\x12\x1e\n" +
"\n" +
"metricName\x18\x01 \x01(\tR\n" +
"metricName\x12\x1e\n" +
"\n" +
"targetSize\x18\x02 \x01(\x03R\n" +
"targetSize\x12(\n" +
"\x0ftargetSizeFloat\x18\x03 \x01(\x01R\x0ftargetSizeFloat\"~\n" +
"\x11GetMetricsRequest\x12I\n" +
"\x0fscaledObjectRef\x18\x01 \x01(\v2\x1f.externalscaler.ScaledObjectRefR\x0fscaledObjectRef\x12\x1e\n" +
"\n" +
"metricName\x18\x02 \x01(\tR\n" +
"metricName\"U\n" +
"\x12GetMetricsResponse\x12?\n" +
"\fmetricValues\x18\x01 \x03(\v2\x1b.externalscaler.MetricValueR\fmetricValues\"{\n" +
"\vMetricValue\x12\x1e\n" +
"\n" +
"metricName\x18\x01 \x01(\tR\n" +
"metricName\x12 \n" +
"\vmetricValue\x18\x02 \x01(\x03R\vmetricValue\x12*\n" +
"\x10metricValueFloat\x18\x03 \x01(\x01R\x10metricValueFloat2\xec\x02\n" +
"\x0eExternalScaler\x12O\n" +
"\bIsActive\x12\x1f.externalscaler.ScaledObjectRef\x1a .externalscaler.IsActiveResponse\"\x00\x12W\n" +
"\x0eStreamIsActive\x12\x1f.externalscaler.ScaledObjectRef\x1a .externalscaler.IsActiveResponse\"\x000\x01\x12Y\n" +
"\rGetMetricSpec\x12\x1f.externalscaler.ScaledObjectRef\x1a%.externalscaler.GetMetricSpecResponse\"\x00\x12U\n" +
"\n" +
"GetMetrics\x12!.externalscaler.GetMetricsRequest\x1a\".externalscaler.GetMetricsResponse\"\x00B\x12Z\x10.;externalscalerb\x06proto3"
var (
file_externalscaler_proto_rawDescOnce sync.Once
file_externalscaler_proto_rawDescData []byte
)
func file_externalscaler_proto_rawDescGZIP() []byte {
file_externalscaler_proto_rawDescOnce.Do(func() {
file_externalscaler_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_externalscaler_proto_rawDesc), len(file_externalscaler_proto_rawDesc)))
})
return file_externalscaler_proto_rawDescData
}
var file_externalscaler_proto_msgTypes = make([]protoimpl.MessageInfo, 8)
var file_externalscaler_proto_goTypes = []any{
(*ScaledObjectRef)(nil), // 0: externalscaler.ScaledObjectRef
(*IsActiveResponse)(nil), // 1: externalscaler.IsActiveResponse
(*GetMetricSpecResponse)(nil), // 2: externalscaler.GetMetricSpecResponse
(*MetricSpec)(nil), // 3: externalscaler.MetricSpec
(*GetMetricsRequest)(nil), // 4: externalscaler.GetMetricsRequest
(*GetMetricsResponse)(nil), // 5: externalscaler.GetMetricsResponse
(*MetricValue)(nil), // 6: externalscaler.MetricValue
nil, // 7: externalscaler.ScaledObjectRef.ScalerMetadataEntry
}
var file_externalscaler_proto_depIdxs = []int32{
7, // 0: externalscaler.ScaledObjectRef.scalerMetadata:type_name -> externalscaler.ScaledObjectRef.ScalerMetadataEntry
3, // 1: externalscaler.GetMetricSpecResponse.metricSpecs:type_name -> externalscaler.MetricSpec
0, // 2: externalscaler.GetMetricsRequest.scaledObjectRef:type_name -> externalscaler.ScaledObjectRef
6, // 3: externalscaler.GetMetricsResponse.metricValues:type_name -> externalscaler.MetricValue
0, // 4: externalscaler.ExternalScaler.IsActive:input_type -> externalscaler.ScaledObjectRef
0, // 5: externalscaler.ExternalScaler.StreamIsActive:input_type -> externalscaler.ScaledObjectRef
0, // 6: externalscaler.ExternalScaler.GetMetricSpec:input_type -> externalscaler.ScaledObjectRef
4, // 7: externalscaler.ExternalScaler.GetMetrics:input_type -> externalscaler.GetMetricsRequest
1, // 8: externalscaler.ExternalScaler.IsActive:output_type -> externalscaler.IsActiveResponse
1, // 9: externalscaler.ExternalScaler.StreamIsActive:output_type -> externalscaler.IsActiveResponse
2, // 10: externalscaler.ExternalScaler.GetMetricSpec:output_type -> externalscaler.GetMetricSpecResponse
5, // 11: externalscaler.ExternalScaler.GetMetrics:output_type -> externalscaler.GetMetricsResponse
8, // [8:12] is the sub-list for method output_type
4, // [4:8] is the sub-list for method input_type
4, // [4:4] is the sub-list for extension type_name
4, // [4:4] is the sub-list for extension extendee
0, // [0:4] is the sub-list for field type_name
}
func init() { file_externalscaler_proto_init() }
func file_externalscaler_proto_init() {
if File_externalscaler_proto != nil {
return
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_externalscaler_proto_rawDesc), len(file_externalscaler_proto_rawDesc)),
NumEnums: 0,
NumMessages: 8,
NumExtensions: 0,
NumServices: 1,
},
GoTypes: file_externalscaler_proto_goTypes,
DependencyIndexes: file_externalscaler_proto_depIdxs,
MessageInfos: file_externalscaler_proto_msgTypes,
}.Build()
File_externalscaler_proto = out.File
file_externalscaler_proto_goTypes = nil
file_externalscaler_proto_depIdxs = nil
}

View File

@ -0,0 +1,239 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc v6.33.4
// source: externalscaler.proto
package externalscaler
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
ExternalScaler_IsActive_FullMethodName = "/externalscaler.ExternalScaler/IsActive"
ExternalScaler_StreamIsActive_FullMethodName = "/externalscaler.ExternalScaler/StreamIsActive"
ExternalScaler_GetMetricSpec_FullMethodName = "/externalscaler.ExternalScaler/GetMetricSpec"
ExternalScaler_GetMetrics_FullMethodName = "/externalscaler.ExternalScaler/GetMetrics"
)
// ExternalScalerClient is the client API for ExternalScaler service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
type ExternalScalerClient interface {
IsActive(ctx context.Context, in *ScaledObjectRef, opts ...grpc.CallOption) (*IsActiveResponse, error)
StreamIsActive(ctx context.Context, in *ScaledObjectRef, opts ...grpc.CallOption) (grpc.ServerStreamingClient[IsActiveResponse], error)
GetMetricSpec(ctx context.Context, in *ScaledObjectRef, opts ...grpc.CallOption) (*GetMetricSpecResponse, error)
GetMetrics(ctx context.Context, in *GetMetricsRequest, opts ...grpc.CallOption) (*GetMetricsResponse, error)
}
type externalScalerClient struct {
cc grpc.ClientConnInterface
}
func NewExternalScalerClient(cc grpc.ClientConnInterface) ExternalScalerClient {
return &externalScalerClient{cc}
}
func (c *externalScalerClient) IsActive(ctx context.Context, in *ScaledObjectRef, opts ...grpc.CallOption) (*IsActiveResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(IsActiveResponse)
err := c.cc.Invoke(ctx, ExternalScaler_IsActive_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *externalScalerClient) StreamIsActive(ctx context.Context, in *ScaledObjectRef, opts ...grpc.CallOption) (grpc.ServerStreamingClient[IsActiveResponse], error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
stream, err := c.cc.NewStream(ctx, &ExternalScaler_ServiceDesc.Streams[0], ExternalScaler_StreamIsActive_FullMethodName, cOpts...)
if err != nil {
return nil, err
}
x := &grpc.GenericClientStream[ScaledObjectRef, IsActiveResponse]{ClientStream: stream}
if err := x.ClientStream.SendMsg(in); err != nil {
return nil, err
}
if err := x.ClientStream.CloseSend(); err != nil {
return nil, err
}
return x, nil
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type ExternalScaler_StreamIsActiveClient = grpc.ServerStreamingClient[IsActiveResponse]
func (c *externalScalerClient) GetMetricSpec(ctx context.Context, in *ScaledObjectRef, opts ...grpc.CallOption) (*GetMetricSpecResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetMetricSpecResponse)
err := c.cc.Invoke(ctx, ExternalScaler_GetMetricSpec_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *externalScalerClient) GetMetrics(ctx context.Context, in *GetMetricsRequest, opts ...grpc.CallOption) (*GetMetricsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetMetricsResponse)
err := c.cc.Invoke(ctx, ExternalScaler_GetMetrics_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// ExternalScalerServer is the server API for ExternalScaler service.
// All implementations must embed UnimplementedExternalScalerServer
// for forward compatibility.
type ExternalScalerServer interface {
IsActive(context.Context, *ScaledObjectRef) (*IsActiveResponse, error)
StreamIsActive(*ScaledObjectRef, grpc.ServerStreamingServer[IsActiveResponse]) error
GetMetricSpec(context.Context, *ScaledObjectRef) (*GetMetricSpecResponse, error)
GetMetrics(context.Context, *GetMetricsRequest) (*GetMetricsResponse, error)
mustEmbedUnimplementedExternalScalerServer()
}
// UnimplementedExternalScalerServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedExternalScalerServer struct{}
func (UnimplementedExternalScalerServer) IsActive(context.Context, *ScaledObjectRef) (*IsActiveResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method IsActive not implemented")
}
func (UnimplementedExternalScalerServer) StreamIsActive(*ScaledObjectRef, grpc.ServerStreamingServer[IsActiveResponse]) error {
return status.Errorf(codes.Unimplemented, "method StreamIsActive not implemented")
}
func (UnimplementedExternalScalerServer) GetMetricSpec(context.Context, *ScaledObjectRef) (*GetMetricSpecResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetMetricSpec not implemented")
}
func (UnimplementedExternalScalerServer) GetMetrics(context.Context, *GetMetricsRequest) (*GetMetricsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetMetrics not implemented")
}
func (UnimplementedExternalScalerServer) mustEmbedUnimplementedExternalScalerServer() {}
func (UnimplementedExternalScalerServer) testEmbeddedByValue() {}
// UnsafeExternalScalerServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to ExternalScalerServer will
// result in compilation errors.
type UnsafeExternalScalerServer interface {
mustEmbedUnimplementedExternalScalerServer()
}
func RegisterExternalScalerServer(s grpc.ServiceRegistrar, srv ExternalScalerServer) {
// If the following call pancis, it indicates UnimplementedExternalScalerServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&ExternalScaler_ServiceDesc, srv)
}
func _ExternalScaler_IsActive_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ScaledObjectRef)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ExternalScalerServer).IsActive(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ExternalScaler_IsActive_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ExternalScalerServer).IsActive(ctx, req.(*ScaledObjectRef))
}
return interceptor(ctx, in, info, handler)
}
func _ExternalScaler_StreamIsActive_Handler(srv interface{}, stream grpc.ServerStream) error {
m := new(ScaledObjectRef)
if err := stream.RecvMsg(m); err != nil {
return err
}
return srv.(ExternalScalerServer).StreamIsActive(m, &grpc.GenericServerStream[ScaledObjectRef, IsActiveResponse]{ServerStream: stream})
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type ExternalScaler_StreamIsActiveServer = grpc.ServerStreamingServer[IsActiveResponse]
func _ExternalScaler_GetMetricSpec_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ScaledObjectRef)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ExternalScalerServer).GetMetricSpec(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ExternalScaler_GetMetricSpec_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ExternalScalerServer).GetMetricSpec(ctx, req.(*ScaledObjectRef))
}
return interceptor(ctx, in, info, handler)
}
func _ExternalScaler_GetMetrics_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetMetricsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ExternalScalerServer).GetMetrics(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ExternalScaler_GetMetrics_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ExternalScalerServer).GetMetrics(ctx, req.(*GetMetricsRequest))
}
return interceptor(ctx, in, info, handler)
}
// ExternalScaler_ServiceDesc is the grpc.ServiceDesc for ExternalScaler service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var ExternalScaler_ServiceDesc = grpc.ServiceDesc{
ServiceName: "externalscaler.ExternalScaler",
HandlerType: (*ExternalScalerServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "IsActive",
Handler: _ExternalScaler_IsActive_Handler,
},
{
MethodName: "GetMetricSpec",
Handler: _ExternalScaler_GetMetricSpec_Handler,
},
{
MethodName: "GetMetrics",
Handler: _ExternalScaler_GetMetrics_Handler,
},
},
Streams: []grpc.StreamDesc{
{
StreamName: "StreamIsActive",
Handler: _ExternalScaler_StreamIsActive_Handler,
ServerStreams: true,
},
},
Metadata: "externalscaler.proto",
}

View File

@ -0,0 +1,18 @@
module gitea-scaler
go 1.25.0
require (
github.com/redis/go-redis/v9 v9.12.1
google.golang.org/grpc v1.76.0
google.golang.org/protobuf v1.36.10
)
require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
golang.org/x/net v0.42.0 // indirect
golang.org/x/sys v0.34.0 // indirect
golang.org/x/text v0.27.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b // indirect
)

View File

@ -0,0 +1,46 @@
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/redis/go-redis/v9 v9.12.1 h1:k5iquqv27aBtnTm2tIkROUDp8JBXhXZIVu1InSgvovg=
github.com/redis/go-redis/v9 v9.12.1/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b h1:zPKJod4w6F1+nRGDI9ubnXYhU9NSWoFAijkHkUXeTK8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=

View File

@ -0,0 +1,374 @@
package main
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/redis/go-redis/v9"
"google.golang.org/grpc"
pb "gitea-scaler/externalscaler"
)
const (
metricName = "gitea_waiting_jobs"
jobKeyPrefix = "gitea-scaler:job:"
labelCountPrefix = "gitea-scaler:label:"
)
type workflowJobPayload struct {
Action string `json:"action"`
WorkflowJob struct {
ID int64 `json:"id"`
Status string `json:"status"`
Labels []string `json:"labels"`
} `json:"workflow_job"`
}
type scalerServer struct {
pb.UnimplementedExternalScalerServer
redis *redis.Client
}
func main() {
redisClient := redis.NewClient(&redis.Options{
Addr: getEnv("REDIS_ADDR", "redis.jam-cloud-infra.svc.cluster.local:6379"),
DB: mustAtoi(getEnv("REDIS_DB", "1")),
})
if err := redisClient.Ping(context.Background()).Err(); err != nil {
log.Fatalf("failed to connect to redis: %v", err)
}
server := &scalerServer{redis: redisClient}
go serveGRPC(server)
go serveHTTP(server)
select {}
}
func serveGRPC(server *scalerServer) {
grpcServer := grpc.NewServer()
pb.RegisterExternalScalerServer(grpcServer, server)
lis, err := net.Listen("tcp", getEnv("GRPC_ADDR", ":50051"))
if err != nil {
log.Fatalf("failed to listen for grpc: %v", err)
}
log.Printf("gRPC scaler listening on %s", lis.Addr())
if err := grpcServer.Serve(lis); err != nil {
log.Fatalf("failed to serve grpc: %v", err)
}
}
func serveHTTP(server *scalerServer) {
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
mux.HandleFunc("/webhooks/workflow-job", server.handleWorkflowJob)
addr := getEnv("HTTP_ADDR", ":8080")
log.Printf("webhook server listening on %s", addr)
if err := http.ListenAndServe(addr, mux); err != nil {
log.Fatalf("failed to serve http: %v", err)
}
}
func (s *scalerServer) handleWorkflowJob(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
event := r.Header.Get("X-Gitea-Event")
if event == "" {
event = r.Header.Get("X-GitHub-Event")
}
if event != "workflow_job" {
http.Error(w, "unsupported event", http.StatusBadRequest)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "failed to read request body", http.StatusBadRequest)
return
}
if err := verifySignature(body, r); err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
var payload workflowJobPayload
if err := json.Unmarshal(body, &payload); err != nil {
http.Error(w, "invalid json payload", http.StatusBadRequest)
return
}
if payload.WorkflowJob.ID == 0 {
http.Error(w, "missing workflow_job.id", http.StatusBadRequest)
return
}
switch normalizeAction(payload) {
case "queued":
if err := s.markQueued(r.Context(), payload.WorkflowJob.ID, payload.WorkflowJob.Labels); err != nil {
http.Error(w, "failed to record queued job", http.StatusInternalServerError)
return
}
case "remove":
if err := s.markComplete(r.Context(), payload.WorkflowJob.ID); err != nil {
http.Error(w, "failed to remove completed job", http.StatusInternalServerError)
return
}
default:
log.Printf("ignoring workflow_job action=%q status=%q id=%d", payload.Action, payload.WorkflowJob.Status, payload.WorkflowJob.ID)
}
w.WriteHeader(http.StatusAccepted)
}
func (s *scalerServer) IsActive(ctx context.Context, ref *pb.ScaledObjectRef) (*pb.IsActiveResponse, error) {
count, err := s.countForLabels(ctx, labelsFromRef(ref))
if err != nil {
return nil, err
}
return &pb.IsActiveResponse{Result: count > 0}, nil
}
func (s *scalerServer) StreamIsActive(ref *pb.ScaledObjectRef, stream pb.ExternalScaler_StreamIsActiveServer) error {
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
for {
count, err := s.countForLabels(stream.Context(), labelsFromRef(ref))
if err != nil {
return err
}
if err := stream.Send(&pb.IsActiveResponse{Result: count > 0}); err != nil {
return err
}
select {
case <-stream.Context().Done():
return nil
case <-ticker.C:
}
}
}
func (s *scalerServer) GetMetricSpec(context.Context, *pb.ScaledObjectRef) (*pb.GetMetricSpecResponse, error) {
return &pb.GetMetricSpecResponse{
MetricSpecs: []*pb.MetricSpec{
{
MetricName: metricName,
TargetSize: 1,
},
},
}, nil
}
func (s *scalerServer) GetMetrics(ctx context.Context, req *pb.GetMetricsRequest) (*pb.GetMetricsResponse, error) {
count, err := s.countForLabels(ctx, labelsFromRef(req.GetScaledObjectRef()))
if err != nil {
return nil, err
}
return &pb.GetMetricsResponse{
MetricValues: []*pb.MetricValue{
{
MetricName: metricName,
MetricValue: int64(count),
},
},
}, nil
}
func (s *scalerServer) markQueued(ctx context.Context, jobID int64, labels []string) error {
jobKey := fmt.Sprintf("%s%d", jobKeyPrefix, jobID)
existingLabels, err := s.redis.SMembers(ctx, jobKey).Result()
if err != nil && !errors.Is(err, redis.Nil) {
return err
}
pipe := s.redis.TxPipeline()
if len(existingLabels) > 0 {
for _, label := range existingLabels {
pipe.Decr(ctx, labelKey(label))
}
pipe.Del(ctx, jobKey)
}
if len(labels) > 0 {
members := make([]interface{}, 0, len(labels))
for _, label := range labels {
normalized := strings.TrimSpace(label)
if normalized == "" {
continue
}
members = append(members, normalized)
pipe.Incr(ctx, labelKey(normalized))
}
if len(members) > 0 {
pipe.SAdd(ctx, jobKey, members...)
}
}
_, err = pipe.Exec(ctx)
return err
}
func (s *scalerServer) markComplete(ctx context.Context, jobID int64) error {
jobKey := fmt.Sprintf("%s%d", jobKeyPrefix, jobID)
labels, err := s.redis.SMembers(ctx, jobKey).Result()
if err != nil && !errors.Is(err, redis.Nil) {
return err
}
if len(labels) == 0 {
return nil
}
pipe := s.redis.TxPipeline()
for _, label := range labels {
pipe.Decr(ctx, labelKey(label))
}
pipe.Del(ctx, jobKey)
_, err = pipe.Exec(ctx)
return err
}
func (s *scalerServer) countForLabels(ctx context.Context, labels []string) (int, error) {
if len(labels) == 0 {
return 0, nil
}
keys := make([]string, 0, len(labels))
for _, label := range labels {
keys = append(keys, labelKey(label))
}
values, err := s.redis.MGet(ctx, keys...).Result()
if err != nil {
return 0, err
}
total := 0
for _, value := range values {
switch v := value.(type) {
case nil:
case string:
n, err := strconv.Atoi(v)
if err != nil {
return 0, err
}
total += max(n, 0)
case int64:
total += max(int(v), 0)
default:
return 0, fmt.Errorf("unexpected redis value type %T", value)
}
}
return total, nil
}
func verifySignature(body []byte, r *http.Request) error {
secret := os.Getenv("WEBHOOK_SECRET")
if secret == "" {
return nil
}
signature := r.Header.Get("X-Gitea-Signature")
if signature == "" {
signature = r.Header.Get("X-Hub-Signature-256")
signature = strings.TrimPrefix(signature, "sha256=")
}
if signature == "" {
return errors.New("missing webhook signature header")
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expected), []byte(strings.ToLower(signature))) {
return errors.New("invalid webhook signature")
}
return nil
}
func labelsFromRef(ref *pb.ScaledObjectRef) []string {
if ref == nil {
return nil
}
labels := strings.Split(ref.ScalerMetadata["labels"], ",")
result := make([]string, 0, len(labels))
for _, label := range labels {
normalized := strings.TrimSpace(label)
if normalized != "" {
result = append(result, normalized)
}
}
return result
}
func normalizeAction(payload workflowJobPayload) string {
action := strings.ToLower(strings.TrimSpace(payload.Action))
status := strings.ToLower(strings.TrimSpace(payload.WorkflowJob.Status))
switch action {
case "queued":
return "queued"
case "in_progress", "completed":
return "remove"
}
switch status {
case "queued":
return "queued"
case "in_progress", "completed":
return "remove"
default:
return ""
}
}
func labelKey(label string) string {
return fmt.Sprintf("%s%s", labelCountPrefix, label)
}
func getEnv(key, fallback string) string {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
return value
}
return fallback
}
func mustAtoi(value string) int {
n, err := strconv.Atoi(value)
if err != nil {
log.Fatalf("invalid integer %q: %v", value, err)
}
return n
}