Support for prefixed Orchard Controller API URLs (#355)

* Support for prefixed Orchard Controller API URLs

* Fix Swagger UI

* Remove spurious "fmt" import

* Use url.URL in order to correctly calculate API path for Swagger UI
This commit is contained in:
Nikolay Edigaryev 2025-10-06 18:04:47 +02:00 committed by GitHub
parent 6d23548d81
commit af221cf3c1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 85 additions and 16 deletions

View File

@ -24,6 +24,7 @@ import (
var ErrRunFailed = errors.New("failed to run controller") var ErrRunFailed = errors.New("failed to run controller")
var address string var address string
var apiPrefix string
var addressSSH string var addressSSH string
var addressPprof string var addressPprof string
var debug bool var debug bool
@ -50,6 +51,9 @@ func newRunCommand() *cobra.Command {
cmd.Flags().StringVarP(&address, "listen", "l", fmt.Sprintf(":%s", port), cmd.Flags().StringVarP(&address, "listen", "l", fmt.Sprintf(":%s", port),
"address to listen on") "address to listen on")
cmd.Flags().StringVar(&apiPrefix, "api-prefix", "",
"prefix to prepend to all Orchard Controller API endpoints; useful when exposing Orchard Controller "+
"behind an HTTP proxy together with other services")
cmd.Flags().StringVar(&addressSSH, "listen-ssh", "", cmd.Flags().StringVar(&addressSSH, "listen-ssh", "",
"address for the built-in SSH server to listen on (e.g. \":6122\")") "address for the built-in SSH server to listen on (e.g. \":6122\")")
cmd.Flags().StringVar(&addressPprof, "listen-pprof", "", cmd.Flags().StringVar(&addressPprof, "listen-pprof", "",
@ -144,6 +148,10 @@ func runController(cmd *cobra.Command, args []string) (err error) {
controller.WithLogger(logger), controller.WithLogger(logger),
} }
if apiPrefix != "" {
controllerOpts = append(controllerOpts, controller.WithAPIPrefix(apiPrefix))
}
var controllerCert tls.Certificate var controllerCert tls.Certificate
if !noTLS { if !noTLS {

View File

@ -5,6 +5,10 @@ package dev
import ( import (
"errors" "errors"
"fmt" "fmt"
"os"
"path"
"path/filepath"
"github.com/cirruslabs/orchard/internal/config" "github.com/cirruslabs/orchard/internal/config"
"github.com/cirruslabs/orchard/internal/controller" "github.com/cirruslabs/orchard/internal/controller"
"github.com/cirruslabs/orchard/internal/netconstants" "github.com/cirruslabs/orchard/internal/netconstants"
@ -13,14 +17,12 @@ import (
v1 "github.com/cirruslabs/orchard/pkg/resource/v1" v1 "github.com/cirruslabs/orchard/pkg/resource/v1"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"go.uber.org/zap" "go.uber.org/zap"
"os"
"path"
"path/filepath"
) )
var ErrFailed = errors.New("failed to run development controller and worker") var ErrFailed = errors.New("failed to run development controller and worker")
var devDataDirPath string var devDataDirPath string
var apiPrefix string
var stringToStringResources map[string]string var stringToStringResources map[string]string
var experimentalRPCV2 bool var experimentalRPCV2 bool
@ -33,6 +35,9 @@ func NewCommand() *cobra.Command {
command.Flags().StringVarP(&devDataDirPath, "data-dir", "d", ".dev-data", command.Flags().StringVarP(&devDataDirPath, "data-dir", "d", ".dev-data",
"path to persist data between runs") "path to persist data between runs")
command.Flags().StringVar(&apiPrefix, "api-prefix", "",
"prefix to prepend to all Orchard Controller API endpoints; useful when exposing Orchard Controller "+
"behind an HTTP proxy together with other services")
command.Flags().StringToStringVar(&stringToStringResources, "resources", map[string]string{}, command.Flags().StringToStringVar(&stringToStringResources, "resources", map[string]string{},
"resources that the development worker will provide") "resources that the development worker will provide")
command.Flags().BoolVar(&experimentalRPCV2, "experimental-rpc-v2", false, command.Flags().BoolVar(&experimentalRPCV2, "experimental-rpc-v2", false,
@ -58,6 +63,10 @@ func runDev(cmd *cobra.Command, args []string) error {
var additionalControllerOpts []controller.Option var additionalControllerOpts []controller.Option
if apiPrefix != "" {
additionalControllerOpts = append(additionalControllerOpts, controller.WithAPIPrefix(apiPrefix))
}
if experimentalRPCV2 { if experimentalRPCV2 {
additionalControllerOpts = append(additionalControllerOpts, controller.WithExperimentalRPCV2()) additionalControllerOpts = append(additionalControllerOpts, controller.WithExperimentalRPCV2())
} }

View File

@ -5,6 +5,7 @@ import (
"crypto/subtle" "crypto/subtle"
"errors" "errors"
"net/http" "net/http"
"net/url"
"strings" "strings"
"github.com/cirruslabs/orchard/api" "github.com/cirruslabs/orchard/api"
@ -28,7 +29,15 @@ var ErrUnauthorized = errors.New("unauthorized")
func (controller *Controller) initAPI() *gin.Engine { func (controller *Controller) initAPI() *gin.Engine {
ginEngine := gin.New() ginEngine := gin.New()
ginEngine.Use( var group *gin.RouterGroup
if controller.apiPrefix != "" {
group = ginEngine.Group(controller.apiPrefix)
} else {
group = ginEngine.Group("/")
}
group.Use(
ginzap.Ginzap(controller.logger.Desugar(), "", true), ginzap.Ginzap(controller.logger.Desugar(), "", true),
ginzap.RecoveryWithZap(controller.logger.Desugar(), true), ginzap.RecoveryWithZap(controller.logger.Desugar(), true),
) )
@ -36,10 +45,10 @@ func (controller *Controller) initAPI() *gin.Engine {
// expose metrics // expose metrics
monitor := ginmetrics.GetMonitor() monitor := ginmetrics.GetMonitor()
monitor.SetMetricPath("/metrics") monitor.SetMetricPath("/metrics")
monitor.Use(ginEngine) monitor.Use(group)
// v1 API // v1 API
v1 := ginEngine.Group("/v1") v1 := group.Group("/v1")
// Auth // Auth
v1.Use(controller.authenticateMiddleware) v1.Use(controller.authenticateMiddleware)
@ -48,9 +57,14 @@ func (controller *Controller) initAPI() *gin.Engine {
// to check that the API is working // to check that the API is working
v1.GET("/", func(c *gin.Context) { v1.GET("/", func(c *gin.Context) {
if controller.enableSwaggerDocs { if controller.enableSwaggerDocs {
apiURL := &url.URL{
Path: "/",
}
apiURL = apiURL.JoinPath(controller.apiPrefix, "v1")
middleware.SwaggerUI(middleware.SwaggerUIOpts{ middleware.SwaggerUI(middleware.SwaggerUIOpts{
Path: "/v1", Path: apiURL.Path,
SpecURL: "/v1/openapi.yaml", SpecURL: apiURL.JoinPath("openapi.yaml").Path,
}, nil).ServeHTTP(c.Writer, c.Request) }, nil).ServeHTTP(c.Writer, c.Request)
} else { } else {
c.Status(http.StatusOK) c.Status(http.StatusOK)

View File

@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"net" "net"
"net/http" "net/http"
"net/url"
"os" "os"
"strings" "strings"
"time" "time"
@ -46,6 +47,7 @@ const (
type Controller struct { type Controller struct {
dataDir *DataDir dataDir *DataDir
listenAddr string listenAddr string
apiPrefix string
tlsConfig *tls.Config tlsConfig *tls.Config
listener net.Listener listener net.Listener
httpServer *http.Server httpServer *http.Server
@ -262,11 +264,17 @@ func (controller *Controller) Run(ctx context.Context) error {
func (controller *Controller) Address() string { func (controller *Controller) Address() string {
hostPort := strings.ReplaceAll(controller.listener.Addr().String(), "[::]", "127.0.0.1") hostPort := strings.ReplaceAll(controller.listener.Addr().String(), "[::]", "127.0.0.1")
if controller.tlsConfig != nil { url := url.URL{
return fmt.Sprintf("https://%s", hostPort) Scheme: "http",
Host: hostPort,
Path: controller.apiPrefix,
} }
return fmt.Sprintf("http://%s", hostPort) if controller.tlsConfig != nil {
url.Scheme = "https"
}
return url.String()
} }
func (controller *Controller) SSHAddress() (string, bool) { func (controller *Controller) SSHAddress() (string, bool) {

View File

@ -22,6 +22,12 @@ func WithListenAddr(listenAddr string) Option {
} }
} }
func WithAPIPrefix(apiPrefix string) Option {
return func(c *Controller) {
c.apiPrefix = apiPrefix
}
}
func WithTLSConfig(tlsConfig *tls.Config) Option { func WithTLSConfig(tlsConfig *tls.Config) Option {
return func(controller *Controller) { return func(controller *Controller) {
controller.tlsConfig = tlsConfig controller.tlsConfig = tlsConfig

View File

@ -0,0 +1,22 @@
package netconstants_test
import (
"testing"
"github.com/cirruslabs/orchard/internal/netconstants"
"github.com/stretchr/testify/require"
)
func TestNormalizeAddress(t *testing.T) {
// Default port
url, err := netconstants.NormalizeAddress("subdomain.example.com/some/prefix")
require.NoError(t, err)
require.Equal(t, "subdomain.example.com:6120", url.Host)
require.Equal(t, "/some/prefix", url.Path)
// Custom port
url, err = netconstants.NormalizeAddress("subdomain.example.com:443/some/prefix")
require.NoError(t, err)
require.Equal(t, "subdomain.example.com:443", url.Host)
require.Equal(t, "/some/prefix", url.Path)
}

View File

@ -9,6 +9,12 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"net"
"net/http"
"net/url"
"time"
"github.com/cirruslabs/orchard/internal/config" "github.com/cirruslabs/orchard/internal/config"
"github.com/cirruslabs/orchard/internal/version" "github.com/cirruslabs/orchard/internal/version"
"github.com/cirruslabs/orchard/rpc" "github.com/cirruslabs/orchard/rpc"
@ -16,11 +22,6 @@ import (
"google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata" "google.golang.org/grpc/metadata"
"io"
"net"
"net/http"
"net/url"
"time"
) )
var ( var (
@ -298,6 +299,7 @@ func (client *Client) formatPath(path string) *url.URL {
Scheme: client.baseURL.Scheme, Scheme: client.baseURL.Scheme,
User: client.baseURL.User, User: client.baseURL.User,
Host: client.baseURL.Host, Host: client.baseURL.Host,
Path: client.baseURL.Path,
} }
return endpointURL.JoinPath("v1", path) return endpointURL.JoinPath("v1", path)