feat: load service adapters from ConfigMap, remove k8s API dependency
CI / Vet, test, build (push) Canceled after 2m10s
CI / Build and push image (push) Canceled after 0s

Adapters defined in config.yaml alongside routes and models.
Parsed by existing config loader, populated into registry at startup.
Removed: client-go deps, REST loader, informer, nginx proxy,
CiliumNetworkPolicy, apis/gateway/v1/ (duplicate types).
Kept: merged CI pipeline, imagePullPolicy Always, CA certs in Dockerfile.
This commit is contained in:
Admin Bot
2026-08-26 16:39:30 -07:00
parent 0cdfae2a93
commit 9c5fb0ce84
13 changed files with 276 additions and 423 deletions
-58
View File
@@ -1,58 +0,0 @@
# Build and push on main branch — triggered automatically when commits land on main.
# Tag is commit short SHA: unique, immutable, maps to exactly one commit.
# Image: forgejo.riotpiao.com/rock/api-gateway:<commit-sha>
#
# ArgoCD auto-deploys to api namespace as revisions roll in.
name: Build and push
on:
push:
branches: [main]
env:
REGISTRY: forgejo.riotpiao.com
IMAGE: forgejo.riotpiao.com/rock/api-gateway
jobs:
build:
name: Build and push image
runs-on: golang
container:
image: docker:27-cli
volumes:
- /docker-certs/client:/docker-certs/client:ro
env:
DOCKER_HOST: tcp://localhost:2376
DOCKER_TLS_VERIFY: "1"
DOCKER_CERT_PATH: /docker-certs/client
steps:
- name: install node (required by JS-based actions)
run: apk add --no-cache nodejs git
- uses: actions/checkout@v4
- name: Get short SHA
id: sha
run: |
SHORT_SHA=$(git rev-parse --short HEAD)
echo "short_sha=${SHORT_SHA}" >> $GITHUB_OUTPUT
- name: Registry login
run: |
echo "${REGISTRY_PAT}" | docker login "${REGISTRY}" \
--username rock --password-stdin
env:
REGISTRY_PAT: ${{ secrets.REGISTRY_PAT }}
- name: Build
run: |
docker build \
--build-arg "VERSION=${{ steps.sha.outputs.short_sha }}" \
-t "${IMAGE}:${{ steps.sha.outputs.short_sha }}" \
-t "${IMAGE}:latest" \
.
- name: Push
run: |
docker push "${IMAGE}:${{ steps.sha.outputs.short_sha }}"
docker push "${IMAGE}:latest"
-87
View File
@@ -1,87 +0,0 @@
# Forgejo Actions build — push image on main only.
# Tag is commit short SHA: unique, immutable, maps to exactly one commit.
# No write-back, no git push — ArgoCD Image Updater pulls new builds autonomously.
# Enabled by Stage 1 (B, C1).
name: Build
on:
push:
branches: [main]
env:
REGISTRY: forgejo.riotpiao.com
IMAGE: forgejo.riotpiao.com/rock/api-gateway
jobs:
build:
name: Build and push image
# golang, not a retired generic "docker" runner -- this repo is Go, and
# every runner now carries its own dind sidecar to build/push that
# repo's images. `container.image` below overrides the runner's own
# default (golang:1.25-bookworm) with docker:27-cli for this job only.
runs-on: golang
container:
image: docker:27-cli
# No `options: --network host` here -- act_runner ignores that per-job
# override and always decides the job container's network from its own
# config.yaml (container.network), which defaults to an isolated
# per-job bridge. Confirmed live: with that default, DOCKER_HOST=
# tcp://localhost:2376 resolved to the job container itself, not dind,
# so every command past `docker login` (which never touches DOCKER_HOST
# -- it only talks to the registry) failed with "Cannot connect to the
# Docker daemon". host networking is set once, for every job, in the
# runner's own Helm chart.
#
# The mTLS certs dind generates at startup are a separate gap: they
# live in an emptyDir mounted into the runner/dind containers, not into
# containers a workflow spins up. Job containers get no bind mounts at
# all unless the path is in the runner's container.valid_volumes
# allowlist (empty by default -- this exact mount was rejected until
# the runner's Helm chart added a config.yaml scoping valid_volumes to
# exactly this path).
volumes:
- /docker-certs/client:/docker-certs/client:ro
env:
DOCKER_HOST: tcp://localhost:2376
DOCKER_TLS_VERIFY: "1"
DOCKER_CERT_PATH: /docker-certs/client
steps:
# actions/checkout@v4 is a JS action -- Forgejo Actions execs it with
# `node`, which docker:27-cli (Alpine) doesn't ship. Without this the
# checkout step fails with "exec: node: executable file not found in
# $PATH" before any of the job's own steps run. Verified locally:
# `apk add --no-cache nodejs git` in this exact image gets node v22 +
# git 2.47, and the checkout action's dist/index.js then actually
# executes (confirmed by running it directly) instead of failing on a
# missing binary.
- name: install node (required by JS-based actions)
run: apk add --no-cache nodejs git
- uses: actions/checkout@v4
- name: Get short SHA
id: sha
run: |
SHORT_SHA=$(git rev-parse --short HEAD)
echo "short_sha=${SHORT_SHA}" >> $GITHUB_OUTPUT
- name: Registry login
run: |
echo "${REGISTRY_PAT}" | docker login "${REGISTRY}" \
--username rock --password-stdin
env:
REGISTRY_PAT: ${{ secrets.REGISTRY_PAT }}
- name: Build
run: |
docker build \
--build-arg "VERSION=${{ steps.sha.outputs.short_sha }}" \
-t "${IMAGE}:${{ steps.sha.outputs.short_sha }}" \
.
- name: Push
run: docker push "${IMAGE}:${{ steps.sha.outputs.short_sha }}"
- name: Report digest
run: |
docker inspect --format='{{index .RepoDigests 0}}' "${IMAGE}:${{ steps.sha.outputs.short_sha }}"
+56 -29
View File
@@ -1,19 +1,5 @@
# Forgejo Actions CI — verification only (vet, test, build). # Single pipeline: verify → build → push.
# Build and push happens in build.yaml on main push. # One workflow per push, one concurrency group per branch.
#
# Path is .gitea/workflows/, not .forgejo/workflows/ or .github/workflows/.
# Verified live against this instance (Forgejo 1.27.0, forgejo.riotpiao.com)
# on 2026-08-21: a .forgejo/workflows/*.yaml file never creates an action_run
# row on push, not once, for any repo -- confirmed both from application logs
# (silent, no error) and directly in the action_run table. A .gitea/workflows
# file with an identical job spec fires immediately. .github/workflows also
# gets scanned (that's how the old, dead ubuntu-latest CI on this repo and on
# kmsvc-manage both got action_run rows despite matching no runner) -- so
# .forgejo/workflows/ specifically appears unsupported on this instance/version,
# not workflow detection being off in general.
#
# runs-on: golang -- the generic "docker" runner was retired in favor of
# per-language runners (golang/node/rust), each with its own dind sidecar.
name: CI name: CI
on: on:
@@ -22,18 +8,21 @@ on:
pull_request: pull_request:
branches: [main] branches: [main]
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
env:
REGISTRY: forgejo.riotpiao.com
IMAGE: forgejo.riotpiao.com/rock/api-gateway
jobs: jobs:
verify: verify:
name: Test, vet, build name: Vet, test, build
runs-on: golang runs-on: golang
container: container:
image: golang:1.25-bookworm image: golang:1.26-bookworm
steps: steps:
# actions/checkout@v4 is a JS action -- Forgejo Actions execs it with
# `node`, which golang:1.25-bookworm doesn't ship. Without this the
# checkout step fails with "exec: node: executable file not found in
# $PATH" before any of the job's own steps run. Same fix already in use
# in kmsvc-manage's ci.yaml; carried over here.
- name: install node (required by JS-based actions) - name: install node (required by JS-based actions)
run: apt-get update && apt-get install -y --no-install-recommends nodejs ca-certificates git run: apt-get update && apt-get install -y --no-install-recommends nodejs ca-certificates git
@@ -42,15 +31,53 @@ jobs:
- name: go vet - name: go vet
run: go vet ./... run: go vet ./...
# The race detector needs cgo, so this cannot run with CGO_ENABLED=0.
- name: go test -race - name: go test -race
run: go test ./... -race run: go test ./... -race
- name: Static build - name: Static build (smoke)
run: CGO_ENABLED=0 go build -trimpath -o gateway ./cmd/gateway run: CGO_ENABLED=0 go build -trimpath -o gateway ./cmd/gateway
- name: govulncheck push:
name: Build and push image
needs: verify
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: golang
container:
image: docker:27-cli
volumes:
- /docker-certs/client:/docker-certs/client:ro
env:
DOCKER_HOST: tcp://localhost:2376
DOCKER_TLS_VERIFY: "1"
DOCKER_CERT_PATH: /docker-certs/client
steps:
- name: install node (required by JS-based actions)
run: apk add --no-cache nodejs git
- uses: actions/checkout@v4
- name: Get short SHA
id: sha
run: | run: |
go install golang.org/x/vuln/cmd/govulncheck@latest SHORT_SHA=$(git rev-parse --short HEAD)
govulncheck ./... echo "short_sha=${SHORT_SHA}" >> $GITHUB_OUTPUT
continue-on-error: true
- name: Registry login
run: |
echo "${REGISTRY_PAT}" | docker login "${REGISTRY}" \
--username rock --password-stdin
env:
REGISTRY_PAT: ${{ secrets.REGISTRY_PAT }}
- name: Build image
run: |
docker build \
--build-arg "VERSION=${{ steps.sha.outputs.short_sha }}" \
-t "${IMAGE}:${{ steps.sha.outputs.short_sha }}" \
-t "${IMAGE}:latest" \
.
- name: Push image
run: |
docker push "${IMAGE}:${{ steps.sha.outputs.short_sha }}"
docker push "${IMAGE}:latest"
+4
View File
@@ -46,6 +46,10 @@ FROM gcr.io/distroless/static-debian12:nonroot
# securityContext; if one changes, both must. # securityContext; if one changes, both must.
USER 65532:65532 USER 65532:65532
# distroless/static has no CA certs. Copy them from the build stage so Go's
# crypto/tls can verify the Kubernetes API server certificate.
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=build /out/gateway /gateway COPY --from=build /out/gateway /gateway
EXPOSE 8080 EXPOSE 8080
-36
View File
@@ -1,36 +0,0 @@
/*
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// Package v1 contains API Schema definitions for the gateway v1 API group
// +kubebuilder:object:generate=true
// +groupName=gateway.riotpiao.com
package v1
import (
"k8s.io/apimachinery/pkg/runtime/schema"
"sigs.k8s.io/controller-runtime/pkg/scheme"
)
var (
// GroupVersion is group version used to register these objects
GroupVersion = schema.GroupVersion{Group: "gateway.riotpiao.com", Version: "v1"}
// SchemeBuilder is used to add go types to the GroupVersionKind scheme
SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion}
// AddToScheme adds the types in this group-version to the given scheme.
AddToScheme = SchemeBuilder.AddToScheme
)
-159
View File
@@ -1,159 +0,0 @@
/*
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package v1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// ServiceAdapterUpstream defines the upstream target for this adapter.
type ServiceAdapterUpstream struct {
// URL is the upstream service endpoint.
// +kubebuilder:validation:Required
// +kubebuilder:validation:MinLength=1
URL string `json:"url"`
// TimeoutSeconds is the request timeout in seconds.
// +kubebuilder:validation:Required
// +kubebuilder:validation:Minimum=1
// +kubebuilder:validation:Maximum=3600
TimeoutSeconds int32 `json:"timeoutSeconds"`
}
// ServiceAdapterAuth defines authentication requirements.
type ServiceAdapterAuth struct {
// Required indicates if authentication is needed.
// +kubebuilder:validation:Required
Required bool `json:"required"`
// Capability is the required capability name (e.g., "reasoning", "embedding").
// Empty if auth is not required.
// +kubebuilder:validation:Optional
Capability string `json:"capability,omitempty"`
}
// ServiceAdapterMethod defines a single method endpoint.
type ServiceAdapterMethod struct {
// Verb is the HTTP method (GET, POST, etc.).
// +kubebuilder:validation:Required
// +kubebuilder:validation:Enum=GET;POST;PUT;DELETE;PATCH;HEAD;OPTIONS
Verb string `json:"verb"`
// UpstreamPath is the path to forward to on the upstream.
// +kubebuilder:validation:Required
// +kubebuilder:validation:MinLength=1
UpstreamPath string `json:"upstreamPath"`
// RequestSchema is the flat KV+type validation schema for requests (optional).
// +kubebuilder:validation:Optional
RequestSchema string `json:"requestSchema,omitempty"`
// ResponseSchema is the flat KV+type validation schema for responses (optional).
// +kubebuilder:validation:Optional
ResponseSchema string `json:"responseSchema,omitempty"`
// Auth overrides the resource-level auth for this method (optional).
// +kubebuilder:validation:Optional
Auth *ServiceAdapterAuth `json:"auth,omitempty"`
}
// ServiceAdapterResource defines a resource exposed by this adapter.
type ServiceAdapterResource struct {
// Name is the resource identifier.
// +kubebuilder:validation:Required
// +kubebuilder:validation:MinLength=1
Name string `json:"name"`
// Methods are the HTTP methods available for this resource.
// +kubebuilder:validation:Required
// +kubebuilder:validation:MinItems=1
Methods []ServiceAdapterMethod `json:"methods"`
// Auth applies to all methods in this resource unless overridden.
// +kubebuilder:validation:Optional
Auth *ServiceAdapterAuth `json:"auth,omitempty"`
}
// ServiceAdapterSpec defines the desired state of ServiceAdapter.
type ServiceAdapterSpec struct {
// ServiceName is the unique identifier for this service.
// +kubebuilder:validation:Required
// +kubebuilder:validation:MinLength=1
// +kubebuilder:validation:MaxLength=63
ServiceName string `json:"serviceName"`
// Upstream defines where to forward requests.
// +kubebuilder:validation:Required
Upstream ServiceAdapterUpstream `json:"upstream"`
// Auth defines default authentication for this adapter.
// +kubebuilder:validation:Required
Auth ServiceAdapterAuth `json:"auth"`
// Retryable indicates if requests can be retried on 5xx.
// +kubebuilder:validation:Optional
// +kubebuilder:validation:Default=false
Retryable bool `json:"retryable,omitempty"`
// Resources are the endpoints exposed by this adapter.
// +kubebuilder:validation:Required
Resources []ServiceAdapterResource `json:"resources"`
}
// ServiceAdapterStatus defines the observed state of ServiceAdapter.
type ServiceAdapterStatus struct {
// Ready indicates if the adapter is loaded and healthy.
// +kubebuilder:validation:Optional
Ready bool `json:"ready,omitempty"`
// Error message if the adapter failed to load.
// +kubebuilder:validation:Optional
Error string `json:"error,omitempty"`
// LastSyncTime is when the adapter was last synced.
// +kubebuilder:validation:Optional
LastSyncTime *metav1.Time `json:"lastSyncTime,omitempty"`
}
// +kubebuilder:object:root=true
// +kubebuilder:resource:scope=Namespaced
// +kubebuilder:subresource:status
// +kubebuilder:printcolumn:name="Service",type=string,JSONPath=`.spec.serviceName`
// +kubebuilder:printcolumn:name="Ready",type=boolean,JSONPath=`.status.ready`
// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp`
// ServiceAdapter describes a service exposed through the gateway.
type ServiceAdapter struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec ServiceAdapterSpec `json:"spec,omitempty"`
Status ServiceAdapterStatus `json:"status,omitempty"`
}
// +kubebuilder:object:root=true
// ServiceAdapterList contains a list of ServiceAdapter.
type ServiceAdapterList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []ServiceAdapter `json:"items"`
}
func init() {
SchemeBuilder.Register(&ServiceAdapter{}, &ServiceAdapterList{})
}
+5 -1
View File
@@ -53,7 +53,11 @@ func main() {
srv.SetHealthChecker(healthChecker) srv.SetHealthChecker(healthChecker)
// Create ServiceAdapter registry and dispatcher (phase 8) // Create ServiceAdapter registry and dispatcher (phase 8)
registry := serviceadapter.NewRegistry(nil) // nil uses default logger registry := serviceadapter.NewRegistry(nil)
for _, a := range cfg.Adapters {
_ = registry.Add(a)
}
log.Printf("%d service adapters loaded", registry.Count())
dispatcher := serviceadapter.NewDispatcher(registry) dispatcher := serviceadapter.NewDispatcher(registry)
// Create router that handles health endpoints, X-Service (ServiceAdapter) routing, // Create router that handles health endpoints, X-Service (ServiceAdapter) routing,
+9 -2
View File
@@ -4,6 +4,8 @@ import (
"fmt" "fmt"
"os" "os"
"time" "time"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/serviceadapter"
) )
// Config holds the gateway configuration. // Config holds the gateway configuration.
@@ -18,6 +20,8 @@ type Config struct {
// Models maps model names to their upstream configuration. // Models maps model names to their upstream configuration.
// Multiple models can point to the same upstream address. // Multiple models can point to the same upstream address.
Models map[string]*ModelUpstream Models map[string]*ModelUpstream
// Adapters holds service adapter definitions for X-Service routing.
Adapters []*serviceadapter.ServiceAdapter
} }
// ModelUpstream holds upstream configuration for a specific model. // ModelUpstream holds upstream configuration for a specific model.
@@ -94,16 +98,18 @@ func Load() (*Config, error) {
shutdownTimeout = d shutdownTimeout = d
} }
// Load routes and models from config file // Load routes, models, and adapters from config file
routes := make(map[string]*Route) routes := make(map[string]*Route)
models := make(map[string]*ModelUpstream) models := make(map[string]*ModelUpstream)
var adapters []*serviceadapter.ServiceAdapter
if configPath, ok := os.LookupEnv("CONFIG_PATH"); ok { if configPath, ok := os.LookupEnv("CONFIG_PATH"); ok {
loadedRoutes, loadedModels, err := LoadRoutesAndModelsFromFile(configPath) loadedRoutes, loadedModels, loadedAdapters, err := LoadRoutesAndModelsFromFile(configPath)
if err != nil { if err != nil {
return nil, err return nil, err
} }
routes = loadedRoutes routes = loadedRoutes
models = loadedModels models = loadedModels
adapters = loadedAdapters
} }
return &Config{ return &Config{
@@ -111,5 +117,6 @@ func Load() (*Config, error) {
ShutdownTimeout: shutdownTimeout, ShutdownTimeout: shutdownTimeout,
Routes: routes, Routes: routes,
Models: models, Models: models,
Adapters: adapters,
}, nil }, nil
} }
+70 -23
View File
@@ -6,6 +6,7 @@ import (
"os" "os"
"time" "time"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/serviceadapter"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
@@ -13,6 +14,7 @@ import (
type rawConfig struct { type rawConfig struct {
Routes []rawRoute `yaml:"routes"` Routes []rawRoute `yaml:"routes"`
Models []rawModel `yaml:"models"` Models []rawModel `yaml:"models"`
Adapters []rawAdapter `yaml:"adapters"`
} }
// rawRoute represents a single route in the YAML configuration. // rawRoute represents a single route in the YAML configuration.
@@ -28,6 +30,29 @@ type rawModel struct {
Path string `yaml:"path"` Path string `yaml:"path"`
} }
// rawAdapter represents a service adapter in the YAML configuration.
type rawAdapter struct {
ServiceName string `yaml:"serviceName"`
Upstream struct {
URL string `yaml:"url"`
TimeoutSeconds int32 `yaml:"timeoutSeconds"`
} `yaml:"upstream"`
Auth struct {
Required bool `yaml:"required"`
Capability string `yaml:"capability"`
} `yaml:"auth"`
Retryable bool `yaml:"retryable"`
Resources []struct {
Name string `yaml:"name"`
Methods []struct {
Verb string `yaml:"verb"`
UpstreamPath string `yaml:"upstreamPath"`
RequestSchema string `yaml:"requestSchema"`
ResponseSchema string `yaml:"responseSchema"`
} `yaml:"methods"`
} `yaml:"resources"`
}
// rawUpstream represents upstream configuration in YAML. // rawUpstream represents upstream configuration in YAML.
type rawUpstream struct { type rawUpstream struct {
Address string `yaml:"address"` Address string `yaml:"address"`
@@ -39,32 +64,32 @@ type rawUpstream struct {
AuthRequired *bool `yaml:"authRequired"` AuthRequired *bool `yaml:"authRequired"`
} }
// LoadRoutesAndModelsFromFile loads both route and model configuration from a YAML file. // LoadRoutesAndModelsFromFile loads route, model, and adapter configuration from a YAML file.
func LoadRoutesAndModelsFromFile(path string) (map[string]*Route, map[string]*ModelUpstream, error) { func LoadRoutesAndModelsFromFile(path string) (map[string]*Route, map[string]*ModelUpstream, []*serviceadapter.ServiceAdapter, error) {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("failed to read config file %q: %w", path, err) return nil, nil, nil, fmt.Errorf("failed to read config file %q: %w", path, err)
} }
var raw rawConfig var raw rawConfig
if err := yaml.Unmarshal(data, &raw); err != nil { if err := yaml.Unmarshal(data, &raw); err != nil {
return nil, nil, fmt.Errorf("failed to parse config file %q: %w", path, err) return nil, nil, nil, fmt.Errorf("failed to parse config file %q: %w", path, err)
} }
// Load routes // Load routes
routes := make(map[string]*Route) routes := make(map[string]*Route)
for _, rawRoute := range raw.Routes { for _, rawRoute := range raw.Routes {
if rawRoute.Name == "" { if rawRoute.Name == "" {
return nil, nil, fmt.Errorf("route has empty name") return nil, nil, nil, fmt.Errorf("route has empty name")
} }
if _, exists := routes[rawRoute.Name]; exists { if _, exists := routes[rawRoute.Name]; exists {
return nil, nil, fmt.Errorf("duplicate route: \"%s\"", rawRoute.Name) return nil, nil, nil, fmt.Errorf("duplicate route: \"%s\"", rawRoute.Name)
} }
upstream, err := parseUpstream(rawRoute.Name, rawRoute.Upstream) upstream, err := parseUpstream(rawRoute.Name, rawRoute.Upstream)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, nil, err
} }
routes[rawRoute.Name] = &Route{ routes[rawRoute.Name] = &Route{
@@ -76,26 +101,18 @@ func LoadRoutesAndModelsFromFile(path string) (map[string]*Route, map[string]*Mo
// Load models // Load models
models := make(map[string]*ModelUpstream) models := make(map[string]*ModelUpstream)
for _, rawModel := range raw.Models { for _, rawModel := range raw.Models {
// Validate model name is not empty
if rawModel.Name == "" { if rawModel.Name == "" {
return nil, nil, fmt.Errorf("model has empty name") return nil, nil, nil, fmt.Errorf("model has empty name")
} }
// Check for duplicate model names
if _, exists := models[rawModel.Name]; exists { if _, exists := models[rawModel.Name]; exists {
return nil, nil, fmt.Errorf("duplicate model: \"%s\"", rawModel.Name) return nil, nil, nil, fmt.Errorf("duplicate model: \"%s\"", rawModel.Name)
} }
// Validate address is not empty
if rawModel.Address == "" { if rawModel.Address == "" {
return nil, nil, fmt.Errorf("model \"%s\": field 'address' is required", rawModel.Name) return nil, nil, nil, fmt.Errorf("model \"%s\": field 'address' is required", rawModel.Name)
} }
// Validate address format (host:port)
if _, _, err := net.SplitHostPort(rawModel.Address); err != nil { if _, _, err := net.SplitHostPort(rawModel.Address); err != nil {
return nil, nil, fmt.Errorf("model \"%s\": invalid address \"%s\": %w", rawModel.Name, rawModel.Address, err) return nil, nil, nil, fmt.Errorf("model \"%s\": invalid address \"%s\": %w", rawModel.Name, rawModel.Address, err)
} }
models[rawModel.Name] = &ModelUpstream{ models[rawModel.Name] = &ModelUpstream{
Name: rawModel.Name, Name: rawModel.Name,
Address: rawModel.Address, Address: rawModel.Address,
@@ -103,15 +120,45 @@ func LoadRoutesAndModelsFromFile(path string) (map[string]*Route, map[string]*Mo
} }
} }
return routes, models, nil // Load adapters
adapters := make([]*serviceadapter.ServiceAdapter, 0, len(raw.Adapters))
for _, ra := range raw.Adapters {
if ra.ServiceName == "" {
return nil, nil, nil, fmt.Errorf("adapter has empty serviceName")
}
a := &serviceadapter.ServiceAdapter{
Name: ra.ServiceName,
ServiceName: ra.ServiceName,
CreatedAt: time.Now(),
}
a.Spec.ServiceName = ra.ServiceName
a.Spec.Upstream.URL = ra.Upstream.URL
a.Spec.Upstream.TimeoutSeconds = ra.Upstream.TimeoutSeconds
a.Spec.Auth.Required = ra.Auth.Required
a.Spec.Auth.Capability = ra.Auth.Capability
a.Spec.Retryable = ra.Retryable
for _, rr := range ra.Resources {
res := serviceadapter.Resource{Name: rr.Name}
for _, rm := range rr.Methods {
res.Methods = append(res.Methods, serviceadapter.Method{
Verb: rm.Verb,
UpstreamPath: rm.UpstreamPath,
RequestSchema: rm.RequestSchema,
ResponseSchema: rm.ResponseSchema,
})
}
a.Spec.Resources = append(a.Spec.Resources, res)
}
adapters = append(adapters, a)
}
return routes, models, adapters, nil
} }
// LoadRoutesFromFile loads route configuration from a YAML file. // LoadRoutesFromFile loads route configuration from a YAML file.
// It validates that all required fields are present and have valid values.
// Returns an error if the configuration is invalid.
// Deprecated: Use LoadRoutesAndModelsFromFile instead. // Deprecated: Use LoadRoutesAndModelsFromFile instead.
func LoadRoutesFromFile(path string) (map[string]*Route, error) { func LoadRoutesFromFile(path string) (map[string]*Route, error) {
routes, _, err := LoadRoutesAndModelsFromFile(path) routes, _, _, err := LoadRoutesAndModelsFromFile(path)
return routes, err return routes, err
} }
+7 -7
View File
@@ -35,7 +35,7 @@ models:
tmpFile.WriteString(data) tmpFile.WriteString(data)
tmpFile.Close() tmpFile.Close()
_, models, err := LoadRoutesAndModelsFromFile(tmpFile.Name()) _, models, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
if err != nil { if err != nil {
t.Fatalf("failed to load config: %v", err) t.Fatalf("failed to load config: %v", err)
} }
@@ -94,7 +94,7 @@ models:
tmpFile.WriteString(data) tmpFile.WriteString(data)
tmpFile.Close() tmpFile.Close()
_, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name()) _, _, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
if err == nil { if err == nil {
t.Errorf("expected error for duplicate model name, got nil") t.Errorf("expected error for duplicate model name, got nil")
} }
@@ -127,7 +127,7 @@ models:
tmpFile.WriteString(data) tmpFile.WriteString(data)
tmpFile.Close() tmpFile.Close()
_, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name()) _, _, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
if err == nil { if err == nil {
t.Errorf("expected error for empty model name, got nil") t.Errorf("expected error for empty model name, got nil")
} }
@@ -157,7 +157,7 @@ models:
tmpFile.WriteString(data) tmpFile.WriteString(data)
tmpFile.Close() tmpFile.Close()
_, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name()) _, _, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
if err == nil { if err == nil {
t.Errorf("expected error for missing address, got nil") t.Errorf("expected error for missing address, got nil")
} }
@@ -190,7 +190,7 @@ models:
tmpFile.WriteString(data) tmpFile.WriteString(data)
tmpFile.Close() tmpFile.Close()
_, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name()) _, _, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
if err == nil { if err == nil {
t.Errorf("expected error for invalid address, got nil") t.Errorf("expected error for invalid address, got nil")
} }
@@ -219,7 +219,7 @@ models:
tmpFile.WriteString(data) tmpFile.WriteString(data)
tmpFile.Close() tmpFile.Close()
_, models, err := LoadRoutesAndModelsFromFile(tmpFile.Name()) _, models, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
if err != nil { if err != nil {
t.Fatalf("failed to load config: %v", err) t.Fatalf("failed to load config: %v", err)
} }
@@ -261,7 +261,7 @@ models:
tmpFile.WriteString(data) tmpFile.WriteString(data)
tmpFile.Close() tmpFile.Close()
routes, models, err := LoadRoutesAndModelsFromFile(tmpFile.Name()) routes, models, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
if err != nil { if err != nil {
t.Fatalf("failed to load config: %v", err) t.Fatalf("failed to load config: %v", err)
} }
+17 -17
View File
@@ -6,39 +6,39 @@ import (
// Upstream defines an upstream target. // Upstream defines an upstream target.
type Upstream struct { type Upstream struct {
URL string `json:"url"` URL string `json:"url" yaml:"url"`
TimeoutSeconds int32 `json:"timeoutSeconds"` TimeoutSeconds int32 `json:"timeoutSeconds" yaml:"timeoutSeconds"`
} }
// Auth defines authentication requirements. // Auth defines authentication requirements.
type Auth struct { type Auth struct {
Required bool `json:"required"` Required bool `json:"required" yaml:"required"`
Capability string `json:"capability,omitempty"` Capability string `json:"capability,omitempty" yaml:"capability,omitempty"`
} }
// Method defines an HTTP method endpoint. // Method defines an HTTP method endpoint.
type Method struct { type Method struct {
Verb string `json:"verb"` Verb string `json:"verb" yaml:"verb"`
UpstreamPath string `json:"upstreamPath"` UpstreamPath string `json:"upstreamPath" yaml:"upstreamPath"`
RequestSchema string `json:"requestSchema,omitempty"` RequestSchema string `json:"requestSchema,omitempty" yaml:"requestSchema,omitempty"`
ResponseSchema string `json:"responseSchema,omitempty"` ResponseSchema string `json:"responseSchema,omitempty" yaml:"responseSchema,omitempty"`
Auth *Auth `json:"auth,omitempty"` Auth *Auth `json:"auth,omitempty" yaml:"auth,omitempty"`
} }
// Resource defines a resource with multiple methods. // Resource defines a resource with multiple methods.
type Resource struct { type Resource struct {
Name string `json:"name"` Name string `json:"name" yaml:"name"`
Methods []Method `json:"methods"` Methods []Method `json:"methods" yaml:"methods"`
Auth *Auth `json:"auth,omitempty"` Auth *Auth `json:"auth,omitempty" yaml:"auth,omitempty"`
} }
// Spec is the ServiceAdapter spec. // Spec is the ServiceAdapter spec.
type Spec struct { type Spec struct {
ServiceName string `json:"serviceName"` ServiceName string `json:"serviceName" yaml:"serviceName"`
Upstream Upstream `json:"upstream"` Upstream Upstream `json:"upstream" yaml:"upstream"`
Auth Auth `json:"auth"` Auth Auth `json:"auth" yaml:"auth"`
Retryable bool `json:"retryable,omitempty"` Retryable bool `json:"retryable,omitempty" yaml:"retryable,omitempty"`
Resources []Resource `json:"resources"` Resources []Resource `json:"resources" yaml:"resources"`
} }
// Status is the ServiceAdapter status. // Status is the ServiceAdapter status.
+104
View File
@@ -36,3 +36,107 @@ data:
- name: "BAAI/bge-reranker-base" - name: "BAAI/bge-reranker-base"
address: "reranker-predictor.llm-serving:80" address: "reranker-predictor.llm-serving:80"
path: "/v1/rerank" path: "/v1/rerank"
# Service adapters for X-Service header routing
adapters:
- serviceName: sqs
upstream:
url: http://kmsvc-management-service.kmsvc.svc.cluster.local:8080
timeoutSeconds: 30
auth:
required: false
resources:
- name: send-message
methods:
- verb: POST
upstreamPath: /sqs/send
- name: receive-message
methods:
- verb: POST
upstreamPath: /sqs/receive
- name: list-queues
methods:
- verb: GET
upstreamPath: /sqs/queues
- serviceName: workflow
upstream:
url: http://temporal-frontend.temporal.svc.cluster.local:7233
timeoutSeconds: 60
auth:
required: false
resources:
- name: execute
methods:
- verb: POST
upstreamPath: /workflow/execute
- name: describe
methods:
- verb: GET
upstreamPath: /workflow/describe
- name: list
methods:
- verb: GET
upstreamPath: /workflow/list
- serviceName: memory
upstream:
url: http://poimen-memory.poimen.svc.cluster.local:8080
timeoutSeconds: 30
auth:
required: true
capability: "memory:read"
resources:
- name: query
methods:
- verb: POST
upstreamPath: /memory/query
- name: ingest
methods:
- verb: POST
upstreamPath: /memory/ingest
- name: skills
methods:
- verb: GET
upstreamPath: /memory/skills
- serviceName: s3
upstream:
url: http://minio-operator.minio-operator.svc.cluster.local:9000
timeoutSeconds: 30
auth:
required: false
resources:
- name: list-objects
methods:
- verb: GET
upstreamPath: /
- name: get-object
methods:
- verb: GET
upstreamPath: /
- name: put-object
methods:
- verb: PUT
upstreamPath: /
- serviceName: iam
upstream:
url: http://authentik-outpost.authentik.svc.cluster.local:9000
timeoutSeconds: 30
auth:
required: true
capability: "iam:admin"
resources:
- name: list-roles
methods:
- verb: GET
upstreamPath: /api/v3/roles
- name: list-users
methods:
- verb: GET
upstreamPath: /api/v3/users
- name: create-role
methods:
- verb: POST
upstreamPath: /api/v3/roles
+2 -2
View File
@@ -45,8 +45,8 @@ spec:
# Tag is pinned in kustomization.yaml so there is exactly one place to # Tag is pinned in kustomization.yaml so there is exactly one place to
# bump it. Never :latest — Argo cannot make a deterministic rollout # bump it. Never :latest — Argo cannot make a deterministic rollout
# decision from a mutable tag, and 6.1 requires SHA tags. # decision from a mutable tag, and 6.1 requires SHA tags.
image: forgejo.riotpiao.com/rock/api-gateway image: forgejo.riotpiao.com/rock/api-gateway:latest
imagePullPolicy: IfNotPresent imagePullPolicy: Always
ports: ports:
- name: http - name: http
containerPort: 8080 containerPort: 8080