Skip to content

Commit c65edc9

Browse files
committed
Add OCI based deployment support for binding providers
1 parent f6dff68 commit c65edc9

15 files changed

Lines changed: 457 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,12 @@ This project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

88
## [Unreleased]
99

10+
## [v0.18.7] - 2026-07-20
11+
1012
### Added
1113

14+
- Added out-of-process binding providers: new binding types (mongodb, redis/valkey, sqlserver, oracle) are built as standalone executables in the [openrundev/bindings](https://github.com/openrundev/bindings) repo and invoked by the server over gRPC using hashicorp/go-plugin, so adding a binding type no longer grows the openrun binary or its dependency tree. Providers are installed with the new `openrun provider install/uninstall/list` commands (`/_openrun/provider` APIs, gated by the new `provider:read` / `provider:manage` RBAC permissions); the install is recorded in the metadata database with pinned sha256 checksums, so it survives restarts and propagates to all server replicas — each replica materializes the verified binaries into the `bindings.cache_dir` local cache at startup, before serving traffic, making installs work on Kubernetes multi-replica deployments.
15+
- Added the OCI image distribution path for binding providers: each provider release also publishes a minimal `FROM scratch` image (`ghcr.io/openrundev/openrun-binding-<name>`), and the Helm chart's `bindings.images` values render one init container per provider which copies the binary (via the provider binary's new `export` subcommand) into a shared volume. The server registers pre-placed provider executables from the new `bindings.preinstalled_dir` config at startup, with no downloads and no database registration — integrity comes from the image digests, and the sha256 computed at discovery is verified on every provider launch. The new `bindings.disable_install` config (chart value `bindings.disableInstall`) rejects the imperative `openrun provider install/uninstall` API/CLI, for deployments where providers are managed only declaratively.
1216
- Add agent builder support using ACP integration
1317
- Added the `builtin` app auth type: username/password authentication (HTTP Basic) against `[builtin_auth.<username>]` config entries, each with a bcrypt `password` hash and a `groups` list used for RBAC `group:` matching (user id is `builtin:<username>` in grants). Users can be defined statically in `openrun.toml` or managed dynamically with the new `openrun user add/update/delete/list` commands (`/_openrun/user` APIs), which take effect immediately without a server restart and shadow static entries of the same name. Useful for small deployments and for testing RBAC policies with multiple users and groups without setting up OAuth/SAML.
1418
- Added the global CLI flag `--as <provider>:<username>` (e.g. `openrun --as builtin:user1 app list`): the management API call runs as the given user with RBAC enforcement (permission checks, list filtering, the owner rule, audit attribution) instead of as the trusted administrator. Supported over the unix domain socket only and requires RBAC to be enabled; for `builtin:` users the entry must exist and its groups feed `group:` grant matching, other provider ids (e.g. `github:user`) are taken literally with no groups so grants for SSO identities can be tested without creating them. Useful for testing RBAC policies from the CLI without going through an app.

Makefile

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ endif
5555
.RECIPEPREFIX = >
5656
TAG :=
5757

58-
.PHONY: help test unit int testui covtest covunit covint release int_single lint verify build-linux image tags docs-screenshots
58+
.PHONY: help test unit int testui covtest covunit covint release fullrelease int_single lint verify build-linux image tags docs-screenshots
5959

6060
help: ## Display this help section
6161
> @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z0-9_-]+:.*?## / {printf "\033[36m%-38s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
@@ -151,6 +151,62 @@ release: ## Tag and push a release; args: <app_version> <helm_version>
151151
> echo "Run above command to push the Helm chart after the OpenRun release job is done"
152152
> @cd - > /dev/null
153153

154+
fullrelease: ## Tag+push openrun, pkg/binding and all bindings under one version; stage (not push) the Helm chart; args: <version>
155+
> @version="$(INPUT)"
156+
> version="$${version#v}"
157+
> if [[ -z "$$version" ]]; then
158+
> echo "Usage: make fullrelease <version>, e.g. make fullrelease 0.19.0"
159+
> exit 1
160+
> fi
161+
> if ! [[ "$$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$$ ]]; then
162+
> echo "Error: version '$$version' does not look like a semver version"
163+
> exit 1
164+
> fi
165+
> # Everything is tagged from the current checkouts: all three repos must be
166+
> # clean, and nothing may be tagged/pushed if any tag already exists
167+
> for repo in . ../bindings ../openrun-helm-charts; do
168+
> if [[ -n "$$(git -C $$repo status --porcelain)" ]]; then
169+
> echo "Error: working tree $$repo is not clean, commit or stash changes first"
170+
> exit 1
171+
> fi
172+
> done
173+
> for tag in "v$$version" "pkg/binding/v$$version"; do
174+
> if git rev-parse -q --verify "refs/tags/$$tag" > /dev/null; then
175+
> echo "Error: tag $$tag already exists"
176+
> exit 1
177+
> fi
178+
> done
179+
> if [[ -n "$$(git -C ../bindings tag -l "*/v$$version")" ]]; then
180+
> echo "Error: bindings tags for v$$version already exist:" $$(git -C ../bindings tag -l "*/v$$version")
181+
> exit 1
182+
> fi
183+
> # openrun server + pkg/binding SDK: tag and push. The SDK tag must be on the
184+
> # remote before the bindings release, whose go mod tidy resolves it.
185+
> git tag -a "v$$version" -m "Release v$$version"
186+
> git tag -a "pkg/binding/v$$version" -m "Release pkg/binding/v$$version"
187+
> git push origin "v$$version" "pkg/binding/v$$version"
188+
> # Bindings: update every provider module to the new SDK version, tag each
189+
> # module and push; the bindings release workflow builds and publishes each
190+
> # provider (binaries + OCI image) from its pushed tag
191+
> $(MAKE) -C ../bindings release INPUT="v$$version" INPUT2="v$$version" PUSH=1
192+
> # Helm chart: stage the release commit only. It is pushed manually after the
193+
> # OpenRun release job has published the v$$version images, since the chart's
194+
> # appVersion is the server image tag.
195+
> cd ../openrun-helm-charts/
196+
> sed -i.bak -E "s/^([[:space:]]*version:[[:space:]]*)[^#[:space:]]+/\1$$version/" charts/openrun/Chart.yaml
197+
> rm -f charts/openrun/Chart.yaml.bak
198+
> sed -i.bak -E "s/^([[:space:]]*appVersion:[[:space:]]*)[^#[:space:]]+/\1$$version/" charts/openrun/Chart.yaml
199+
> rm -f charts/openrun/Chart.yaml.bak
200+
> git add charts/openrun/Chart.yaml
201+
> git commit -m "Updated Helm chart to $$version, app version to $$version"
202+
> cd - > /dev/null
203+
> echo "**************************************************"
204+
> echo " Tagged and pushed: v$$version, pkg/binding/v$$version, bindings */v$$version"
205+
> echo " Helm chart commit staged in ../openrun-helm-charts (not pushed)"
206+
> echo " After the OpenRun release job for v$$version is done, run:"
207+
> echo " cd ../openrun-helm-charts/ && git push"
208+
> echo "**************************************************"
209+
154210
# Swallow extra command-line words (e.g. `make int_single test_reload.yaml`)
155211
# so make doesn't also try to build them as targets; $(INPUT)/$(INPUT2) above
156212
# already pick them up positionally.

deploy/terraform/README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,19 @@ Wait for DNS to propagate before completing TLS issuance.
105105

106106
Set `openrun_auth_mode` to `oidc` or `saml` and provide the matching variables. Outputs include the callback/ACS URLs.
107107

108+
## Binding providers (optional)
109+
110+
Out-of-process [binding providers](https://github.com/openrundev/bindings) (mongodb, redis, sqlserver, oracle) can be installed declaratively:
111+
112+
```hcl
113+
openrun_binding_providers = {
114+
redis = "v0.1.0"
115+
mongodb = "v0.1.0"
116+
}
117+
```
118+
119+
Every OpenRun replica downloads, verifies and registers the declared providers at startup; their service types become available for `openrun service create`. Upgrading a provider is a version change followed by `terraform apply` (which rolls out the deployment). Append `@sha256:<hex>` to a version to pin the binary digest. Set `openrun_bindings_release_url_template` to download from an internal mirror when the cluster restricts egress.
120+
108121
## Security summary
109122

110123
- EKS worker nodes are private and egress via NAT.

deploy/terraform/openrun.tf

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,10 @@ locals {
107107
}
108108
auth = local.openrun_oidc_values
109109
saml = local.openrun_saml_values
110+
bindings = {
111+
install = var.openrun_binding_providers
112+
releaseUrlTemplate = var.openrun_bindings_release_url_template
113+
}
110114
}
111115
}
112116

deploy/terraform/tfvars.sample

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,24 @@ openrun_chart_version = ""
3232
openrun_default_domain = ""
3333

3434
# Email for Let's Encrypt certificate registration. Required for Let's Encrypt certificate issuance.
35-
openrun_lets_encrypt_email = "
35+
openrun_lets_encrypt_email = ""
3636

3737
# Use Let's Encrypt staging environment (set to true for testing to avoid rate limits)
3838
openrun_lets_encrypt_use_staging = false
3939

40+
# Out-of-process binding providers (https://github.com/openrundev/bindings) installed
41+
# at server startup, provider name to version. Append @sha256:<hex> to a version to
42+
# pin the binary digest (comma-separated digests for multi-arch node groups).
43+
# openrun_binding_providers = {
44+
# redis = "v0.1.0"
45+
# mongodb = "v0.1.0"
46+
# }
47+
48+
# Mirror url template for provider downloads when the cluster restricts egress.
49+
# {provider}, {version}, {os} and {arch} are replaced. Leave empty to download
50+
# from the openrundev/bindings GitHub releases.
51+
# openrun_bindings_release_url_template = ""
52+
4053
# -----------------------------------------------------------------------------
4154
# EKS Cluster Configuration
4255
# -----------------------------------------------------------------------------

deploy/terraform/variables.tf

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,18 @@ variable "openrun_enable_nlb_eips" {
249249
default = true
250250
}
251251

252+
variable "openrun_binding_providers" {
253+
description = "Out-of-process binding providers installed at server startup, provider name to version (e.g. { redis = \"v0.1.0\" }). Append @sha256:<hex> to a version to pin the binary digest (comma-separated digests for multi-arch). Providers declared here cannot be modified with the openrun provider CLI."
254+
type = map(string)
255+
default = {}
256+
}
257+
258+
variable "openrun_bindings_release_url_template" {
259+
description = "Optional mirror url template for downloading binding provider binaries ({provider}, {version}, {os}, {arch} are replaced). Leave empty to download from the openrundev/bindings GitHub releases."
260+
type = string
261+
default = ""
262+
}
263+
252264
variable "openrun_auth_mode" {
253265
description = "Authentication mode for OpenRun: none, oidc, or saml."
254266
type = string

docs/content/docs/Container/Kubernetes.md

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,28 @@ OpenRun service and Kaniko jobs run in the main namespace (default `openrun`). A
103103

104104
Out-of-process [binding providers](https://github.com/openrundev/bindings) (mongodb, redis, sqlserver, oracle) work on Kubernetes without any special setup: installed providers are registered in the metadata database (Postgres, required for Kubernetes installs), which is the source of truth. Each server replica materializes the provider executables into a node-local cache directory at startup, before serving traffic, and re-downloads them (with checksum verification) whenever a pod starts on a fresh node. Installs and uninstalls propagate to all replicas through Postgres notifications, so `openrun provider install` works on multi-replica deployments exactly as on a single server.
105105

106-
For declarative, config-managed deployments, declare the providers in the server config instead of using the CLI:
106+
For declarative, config-managed deployments, declare the providers in the Helm values instead of using the CLI:
107+
108+
```yaml {filename="values.yaml"}
109+
bindings:
110+
install:
111+
redis: v0.1.0
112+
mongodb: v0.1.0
113+
# Optional mirror for clusters with restricted egress; {provider}, {version},
114+
# {os} and {arch} are replaced.
115+
# releaseUrlTemplate: "https://mirror.internal/{provider}/{version}/openrun-binding-{provider}-{os}-{arch}"
116+
```
117+
118+
or with `--set bindings.install.redis=v0.1.0` on the Helm command line. For Terraform-managed EKS installs, set the `openrun_binding_providers` variable in `terraform.tfvars`:
119+
120+
```hcl {filename="terraform.tfvars"}
121+
openrun_binding_providers = {
122+
redis = "v0.1.0"
123+
mongodb = "v0.1.0"
124+
}
125+
```
126+
127+
The chart renders these into the `[bindings.install]` section of the generated server config:
107128

108129
```toml {filename="openrun.toml"}
109130
[bindings.install]
@@ -117,6 +138,21 @@ A replica that misses an install notification (for example after a transient dat
117138

118139
The provider processes run inside the OpenRun server pod, so a provider binary must run in the server container image; the published providers are static pure-Go binaries and need nothing beyond the stock image. Provider startup materialization completes before the server starts serving, so binding operations never race provider registration during a rollout.
119140

141+
### OCI image install
142+
143+
Providers are also published as minimal OCI images (`ghcr.io/openrundev/openrun-binding-<provider>`, a `FROM scratch` image holding the provider binary). For clusters where binaries must come through image provenance policies, or where the server pods have no egress at all, declare the providers as images instead of versions:
144+
145+
```yaml {filename="values.yaml"}
146+
bindings:
147+
images:
148+
redis: ghcr.io/openrundev/openrun-binding-redis:v0.1.0
149+
mongodb: ghcr.io/openrundev/openrun-binding-mongodb@sha256:8f4e...
150+
```
151+
152+
The Helm chart renders one init container per provider, which copies the binary from its image into a shared volume (`export` subcommand of the provider binary); the server registers the pre-placed providers at startup, with no downloads and no database registration. Integrity comes from the image digests that placed the binaries — reference images by digest where policy requires it — and the server pins the discovered binary's checksum so every provider launch verifies the file has not changed. Images are pulled by the container runtime, so `imagePullSecrets` and registry mirrors apply as for any other image.
153+
154+
With either declarative path, `bindings.disableInstall: true` additionally rejects the imperative `openrun provider install`/`uninstall` CLI on the deployment, making the chart values the only way providers are added.
155+
120156
## Architecture
121157

122158
OpenRun is installed as a Kubernetes Deployment, with a Service for routing API calls. For each containerized app installed on OpenRun, a ClusterIP Service is created for app traffic. API calls to the OpenRun API Server are routed to the app-specific Service using its cluster IP.

internal/server/provider.go

Lines changed: 73 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,20 @@ func (s *Server) isConfigManagedProvider(name string) bool {
244244
return ok
245245
}
246246

247+
// providerModifyError returns the error rejecting an imperative provider
248+
// install/uninstall when the deployment manages providers declaratively:
249+
// either globally (bindings.disable_install) or for one config-declared
250+
// provider. operation is "install" or "uninstall", for the error text.
251+
func (s *Server) providerModifyError(name, operation string) error {
252+
if s.staticConfig.Bindings.DisableInstall {
253+
return fmt.Errorf("provider %s is disabled on this server (bindings.disable_install), providers are managed through the server config", operation)
254+
}
255+
if s.isConfigManagedProvider(name) {
256+
return fmt.Errorf("provider %s is managed through the [bindings.install] server config, update the config instead", name)
257+
}
258+
return nil
259+
}
260+
247261
// InstallProvider installs or updates an out-of-process binding provider: the
248262
// binary is fetched, verified, registered in the metadata database (the source
249263
// of truth) and materialized into the local cache dir. Other replicas
@@ -252,8 +266,8 @@ func (s *Server) InstallProvider(ctx context.Context, request *types.ProviderIns
252266
if err := s.enforceGlobalPerm(ctx, types.PermissionProviderManage, ""); err != nil {
253267
return nil, err
254268
}
255-
if s.isConfigManagedProvider(request.Name) {
256-
return nil, fmt.Errorf("provider %s is managed through the [bindings.install] server config, update the config instead", request.Name)
269+
if err := s.providerModifyError(request.Name, "install"); err != nil {
270+
return nil, err
257271
}
258272
return s.installProvider(ctx, request, system.GetContextUserId(ctx))
259273
}
@@ -401,8 +415,8 @@ func (s *Server) UninstallProvider(ctx context.Context, name string, force bool)
401415
if err := s.enforceGlobalPerm(ctx, types.PermissionProviderManage, ""); err != nil {
402416
return err
403417
}
404-
if s.isConfigManagedProvider(name) {
405-
return fmt.Errorf("provider %s is managed through the [bindings.install] server config, remove it from the config instead", name)
418+
if err := s.providerModifyError(name, "uninstall"); err != nil {
419+
return err
406420
}
407421
s.providerMutex.Lock()
408422
defer s.providerMutex.Unlock()
@@ -504,6 +518,8 @@ func (s *Server) setupBindingProviders(ctx context.Context) {
504518
Msg("Registered DEV binding provider from local path, checksum verification is disabled")
505519
}
506520

521+
s.registerPreinstalledProviders(ctx)
522+
507523
// Install providers declared in the config: the declarative path for
508524
// config-managed (Kubernetes/Helm) deployments, where every replica runs
509525
// this on startup. The install is skipped when the database row already
@@ -534,6 +550,59 @@ func (s *Server) setupBindingProviders(ctx context.Context) {
534550
}
535551
}
536552

553+
// registerPreinstalledProviders discovers provider executables pre-placed in
554+
// bindings.preinstalled_dir and registers their service types, without
555+
// database registration or downloads. This is the Kubernetes OCI image
556+
// distribution path: init containers copy each provider binary from its
557+
// per-provider image into a shared volume before the server starts, so
558+
// integrity comes from the image digests that placed the files. The sha256
559+
// computed here is still pinned in the registration, so every launch verifies
560+
// the file has not changed since discovery. Preinstalled providers register
561+
// before database reconcile: a database-installed provider claiming the same
562+
// service types fails reconcile with a logged conflict.
563+
func (s *Server) registerPreinstalledProviders(ctx context.Context) {
564+
dir := os.ExpandEnv(s.staticConfig.Bindings.PreinstalledDir)
565+
if dir == "" {
566+
return
567+
}
568+
entries, err := os.ReadDir(dir)
569+
if err != nil {
570+
s.Error().Err(err).Str("dir", dir).Msg("error reading preinstalled bindings dir")
571+
return
572+
}
573+
for _, entry := range entries {
574+
name, found := strings.CutPrefix(entry.Name(), "openrun-binding-")
575+
name = strings.TrimSuffix(name, ".exe")
576+
if !found || name == "" || entry.IsDir() {
577+
continue
578+
}
579+
execPath := filepath.Join(dir, entry.Name())
580+
data, err := os.ReadFile(execPath)
581+
if err != nil {
582+
s.Error().Err(err).Str("path", execPath).Msg("error reading preinstalled binding provider")
583+
continue
584+
}
585+
sum := sha256.Sum256(data)
586+
checksum := hex.EncodeToString(sum[:])
587+
588+
version, serviceTypes, err := s.describeProvider(ctx, execPath, checksum)
589+
if err != nil {
590+
s.Error().Err(err).Str("provider", name).Str("path", execPath).Msg("error describing preinstalled binding provider")
591+
continue
592+
}
593+
typeNames := make([]string, 0, len(serviceTypes))
594+
for _, t := range serviceTypes {
595+
typeNames = append(typeNames, t.ServiceType)
596+
}
597+
if err := bindings.ReplaceProviderBindings("preinstalled:"+name, typeNames, execPath, checksum); err != nil {
598+
s.Error().Err(err).Str("provider", name).Msg("error registering preinstalled binding provider")
599+
continue
600+
}
601+
s.Info().Str("provider", name).Str("path", execPath).Str("version", version).Strs("service_types", typeNames).
602+
Msg("Registered preinstalled binding provider")
603+
}
604+
}
605+
537606
// ensureConfigProvider installs one [bindings.install] entry if the database
538607
// does not already have it at the declared version. The following reconcile
539608
// pass materializes the binary into the local cache when needed.

0 commit comments

Comments
 (0)