feat: load service adapters from ConfigMap, remove k8s API dependency
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:
@@ -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"
|
||||
@@ -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
@@ -1,19 +1,5 @@
|
||||
# Forgejo Actions CI — verification only (vet, test, build).
|
||||
# Build and push happens in build.yaml on main push.
|
||||
#
|
||||
# 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.
|
||||
# Single pipeline: verify → build → push.
|
||||
# One workflow per push, one concurrency group per branch.
|
||||
name: CI
|
||||
|
||||
on:
|
||||
@@ -22,18 +8,21 @@ on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
REGISTRY: forgejo.riotpiao.com
|
||||
IMAGE: forgejo.riotpiao.com/rock/api-gateway
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
name: Test, vet, build
|
||||
name: Vet, test, build
|
||||
runs-on: golang
|
||||
container:
|
||||
image: golang:1.25-bookworm
|
||||
image: golang:1.26-bookworm
|
||||
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)
|
||||
run: apt-get update && apt-get install -y --no-install-recommends nodejs ca-certificates git
|
||||
|
||||
@@ -42,15 +31,53 @@ jobs:
|
||||
- name: go vet
|
||||
run: go vet ./...
|
||||
|
||||
# The race detector needs cgo, so this cannot run with CGO_ENABLED=0.
|
||||
- name: 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
|
||||
|
||||
- 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: |
|
||||
go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||
govulncheck ./...
|
||||
continue-on-error: true
|
||||
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 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"
|
||||
|
||||
@@ -46,6 +46,10 @@ FROM gcr.io/distroless/static-debian12:nonroot
|
||||
# securityContext; if one changes, both must.
|
||||
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
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
@@ -53,7 +53,11 @@ func main() {
|
||||
srv.SetHealthChecker(healthChecker)
|
||||
|
||||
// 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)
|
||||
|
||||
// Create router that handles health endpoints, X-Service (ServiceAdapter) routing,
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/serviceadapter"
|
||||
)
|
||||
|
||||
// Config holds the gateway configuration.
|
||||
@@ -18,6 +20,8 @@ type Config struct {
|
||||
// Models maps model names to their upstream configuration.
|
||||
// Multiple models can point to the same upstream address.
|
||||
Models map[string]*ModelUpstream
|
||||
// Adapters holds service adapter definitions for X-Service routing.
|
||||
Adapters []*serviceadapter.ServiceAdapter
|
||||
}
|
||||
|
||||
// ModelUpstream holds upstream configuration for a specific model.
|
||||
@@ -94,16 +98,18 @@ func Load() (*Config, error) {
|
||||
shutdownTimeout = d
|
||||
}
|
||||
|
||||
// Load routes and models from config file
|
||||
// Load routes, models, and adapters from config file
|
||||
routes := make(map[string]*Route)
|
||||
models := make(map[string]*ModelUpstream)
|
||||
var adapters []*serviceadapter.ServiceAdapter
|
||||
if configPath, ok := os.LookupEnv("CONFIG_PATH"); ok {
|
||||
loadedRoutes, loadedModels, err := LoadRoutesAndModelsFromFile(configPath)
|
||||
loadedRoutes, loadedModels, loadedAdapters, err := LoadRoutesAndModelsFromFile(configPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
routes = loadedRoutes
|
||||
models = loadedModels
|
||||
adapters = loadedAdapters
|
||||
}
|
||||
|
||||
return &Config{
|
||||
@@ -111,5 +117,6 @@ func Load() (*Config, error) {
|
||||
ShutdownTimeout: shutdownTimeout,
|
||||
Routes: routes,
|
||||
Models: models,
|
||||
Adapters: adapters,
|
||||
}, nil
|
||||
}
|
||||
|
||||
+70
-23
@@ -6,6 +6,7 @@ import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/serviceadapter"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
type rawConfig struct {
|
||||
Routes []rawRoute `yaml:"routes"`
|
||||
Models []rawModel `yaml:"models"`
|
||||
Adapters []rawAdapter `yaml:"adapters"`
|
||||
}
|
||||
|
||||
// rawRoute represents a single route in the YAML configuration.
|
||||
@@ -28,6 +30,29 @@ type rawModel struct {
|
||||
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.
|
||||
type rawUpstream struct {
|
||||
Address string `yaml:"address"`
|
||||
@@ -39,32 +64,32 @@ type rawUpstream struct {
|
||||
AuthRequired *bool `yaml:"authRequired"`
|
||||
}
|
||||
|
||||
// LoadRoutesAndModelsFromFile loads both route and model configuration from a YAML file.
|
||||
func LoadRoutesAndModelsFromFile(path string) (map[string]*Route, map[string]*ModelUpstream, error) {
|
||||
// LoadRoutesAndModelsFromFile loads route, model, and adapter configuration from a YAML file.
|
||||
func LoadRoutesAndModelsFromFile(path string) (map[string]*Route, map[string]*ModelUpstream, []*serviceadapter.ServiceAdapter, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
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
|
||||
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
|
||||
routes := make(map[string]*Route)
|
||||
for _, rawRoute := range raw.Routes {
|
||||
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 {
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
routes[rawRoute.Name] = &Route{
|
||||
@@ -76,26 +101,18 @@ func LoadRoutesAndModelsFromFile(path string) (map[string]*Route, map[string]*Mo
|
||||
// Load models
|
||||
models := make(map[string]*ModelUpstream)
|
||||
for _, rawModel := range raw.Models {
|
||||
// Validate model name is not empty
|
||||
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 {
|
||||
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 == "" {
|
||||
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 {
|
||||
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{
|
||||
Name: rawModel.Name,
|
||||
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.
|
||||
// It validates that all required fields are present and have valid values.
|
||||
// Returns an error if the configuration is invalid.
|
||||
// Deprecated: Use LoadRoutesAndModelsFromFile instead.
|
||||
func LoadRoutesFromFile(path string) (map[string]*Route, error) {
|
||||
routes, _, err := LoadRoutesAndModelsFromFile(path)
|
||||
routes, _, _, err := LoadRoutesAndModelsFromFile(path)
|
||||
return routes, err
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ models:
|
||||
tmpFile.WriteString(data)
|
||||
tmpFile.Close()
|
||||
|
||||
_, models, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
|
||||
_, models, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load config: %v", err)
|
||||
}
|
||||
@@ -94,7 +94,7 @@ models:
|
||||
tmpFile.WriteString(data)
|
||||
tmpFile.Close()
|
||||
|
||||
_, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
|
||||
_, _, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
|
||||
if err == nil {
|
||||
t.Errorf("expected error for duplicate model name, got nil")
|
||||
}
|
||||
@@ -127,7 +127,7 @@ models:
|
||||
tmpFile.WriteString(data)
|
||||
tmpFile.Close()
|
||||
|
||||
_, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
|
||||
_, _, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
|
||||
if err == nil {
|
||||
t.Errorf("expected error for empty model name, got nil")
|
||||
}
|
||||
@@ -157,7 +157,7 @@ models:
|
||||
tmpFile.WriteString(data)
|
||||
tmpFile.Close()
|
||||
|
||||
_, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
|
||||
_, _, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
|
||||
if err == nil {
|
||||
t.Errorf("expected error for missing address, got nil")
|
||||
}
|
||||
@@ -190,7 +190,7 @@ models:
|
||||
tmpFile.WriteString(data)
|
||||
tmpFile.Close()
|
||||
|
||||
_, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
|
||||
_, _, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
|
||||
if err == nil {
|
||||
t.Errorf("expected error for invalid address, got nil")
|
||||
}
|
||||
@@ -219,7 +219,7 @@ models:
|
||||
tmpFile.WriteString(data)
|
||||
tmpFile.Close()
|
||||
|
||||
_, models, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
|
||||
_, models, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load config: %v", err)
|
||||
}
|
||||
@@ -261,7 +261,7 @@ models:
|
||||
tmpFile.WriteString(data)
|
||||
tmpFile.Close()
|
||||
|
||||
routes, models, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
|
||||
routes, models, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load config: %v", err)
|
||||
}
|
||||
|
||||
@@ -6,39 +6,39 @@ import (
|
||||
|
||||
// Upstream defines an upstream target.
|
||||
type Upstream struct {
|
||||
URL string `json:"url"`
|
||||
TimeoutSeconds int32 `json:"timeoutSeconds"`
|
||||
URL string `json:"url" yaml:"url"`
|
||||
TimeoutSeconds int32 `json:"timeoutSeconds" yaml:"timeoutSeconds"`
|
||||
}
|
||||
|
||||
// Auth defines authentication requirements.
|
||||
type Auth struct {
|
||||
Required bool `json:"required"`
|
||||
Capability string `json:"capability,omitempty"`
|
||||
Required bool `json:"required" yaml:"required"`
|
||||
Capability string `json:"capability,omitempty" yaml:"capability,omitempty"`
|
||||
}
|
||||
|
||||
// Method defines an HTTP method endpoint.
|
||||
type Method struct {
|
||||
Verb string `json:"verb"`
|
||||
UpstreamPath string `json:"upstreamPath"`
|
||||
RequestSchema string `json:"requestSchema,omitempty"`
|
||||
ResponseSchema string `json:"responseSchema,omitempty"`
|
||||
Auth *Auth `json:"auth,omitempty"`
|
||||
Verb string `json:"verb" yaml:"verb"`
|
||||
UpstreamPath string `json:"upstreamPath" yaml:"upstreamPath"`
|
||||
RequestSchema string `json:"requestSchema,omitempty" yaml:"requestSchema,omitempty"`
|
||||
ResponseSchema string `json:"responseSchema,omitempty" yaml:"responseSchema,omitempty"`
|
||||
Auth *Auth `json:"auth,omitempty" yaml:"auth,omitempty"`
|
||||
}
|
||||
|
||||
// Resource defines a resource with multiple methods.
|
||||
type Resource struct {
|
||||
Name string `json:"name"`
|
||||
Methods []Method `json:"methods"`
|
||||
Auth *Auth `json:"auth,omitempty"`
|
||||
Name string `json:"name" yaml:"name"`
|
||||
Methods []Method `json:"methods" yaml:"methods"`
|
||||
Auth *Auth `json:"auth,omitempty" yaml:"auth,omitempty"`
|
||||
}
|
||||
|
||||
// Spec is the ServiceAdapter spec.
|
||||
type Spec struct {
|
||||
ServiceName string `json:"serviceName"`
|
||||
Upstream Upstream `json:"upstream"`
|
||||
Auth Auth `json:"auth"`
|
||||
Retryable bool `json:"retryable,omitempty"`
|
||||
Resources []Resource `json:"resources"`
|
||||
ServiceName string `json:"serviceName" yaml:"serviceName"`
|
||||
Upstream Upstream `json:"upstream" yaml:"upstream"`
|
||||
Auth Auth `json:"auth" yaml:"auth"`
|
||||
Retryable bool `json:"retryable,omitempty" yaml:"retryable,omitempty"`
|
||||
Resources []Resource `json:"resources" yaml:"resources"`
|
||||
}
|
||||
|
||||
// Status is the ServiceAdapter status.
|
||||
|
||||
@@ -36,3 +36,107 @@ data:
|
||||
- name: "BAAI/bge-reranker-base"
|
||||
address: "reranker-predictor.llm-serving:80"
|
||||
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
@@ -45,8 +45,8 @@ spec:
|
||||
# Tag is pinned in kustomization.yaml so there is exactly one place to
|
||||
# bump it. Never :latest — Argo cannot make a deterministic rollout
|
||||
# decision from a mutable tag, and 6.1 requires SHA tags.
|
||||
image: forgejo.riotpiao.com/rock/api-gateway
|
||||
imagePullPolicy: IfNotPresent
|
||||
image: forgejo.riotpiao.com/rock/api-gateway:latest
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8080
|
||||
|
||||
Reference in New Issue
Block a user