diff --git a/docs/faq.md b/docs/faq.md
index fada19e..2569847 100644
--- a/docs/faq.md
+++ b/docs/faq.md
@@ -2,15 +2,18 @@
## 1. Is Horizon an alternative to Kubernetes?
-No. You might run Horizon on Kubernetes, or use Horizon to provision Kubernetes resources.
-So why build Horizon? To combine the controller model, an API and a web UI that is easy to extend and test.
+If you are using Kubernetes to run containers and workloads then no.
-For simple environments (e.g. home labs) you could write a container scheduler using controllers and actors and have Horizon run containers for you as an alternative to Kubernetes.
+If you are extending Kubernetes with CustomResourceDefinitions and Controllers to provide self-service capabitilies, like a Platform Team would do, then potentially yes.
+Building operators (custom resource definitions + controllers) is the main use case for Horizon.
+
+For fun (e.g. home labs) you could write a container scheduler using controllers and actors and have Horizon run containers for you as an alternative to Kubernetes.
## 2. If we need to automate everything anyway, isn't this just more work?
Absolutely. Using Horizon is more work than just shipping some Terraform or Ansible scripts to end users.
-Do some reading on Developer Experience and Platform Engineering.
+Our recommendation would be to do some reading on Developer Experience and Platform Engineering.
+Shipping Terraform modules with documentation and a PR-based workflow is **not** (in our eyes) a real platform and provides a terrible Developer Experience.
Architecting abstractions to enable developer flow is not easy and requires effort and time.
The more developers using your platform, the more value there is in doing so.
@@ -30,3 +33,5 @@ Reconcile logic could be ported to CI pipelines or some other automated event.
Portals are just Go servers that you could run somewhere else.
Like any tool, you will have to invest in Horizon to make it meaningful, but the idea that you get "locked in" because you write Go code (as opposed to bash) is an unfounded one.
+
+You can always port your Go code to a Kubernetes Controller.
diff --git a/docs/rbac.md b/docs/rbac.md
new file mode 100644
index 0000000..ad1be39
--- /dev/null
+++ b/docs/rbac.md
@@ -0,0 +1,38 @@
+# Role Based Access Control (RBAC)
+
+This page talks about the RBAC model that Horizon uses.
+
+At this time, only one very rudimentary model is supported, which is inspired by [Kubernetes' RBAC model](https://kubernetes.io/docs/reference/access-authn-authz/rbac/).
+
+## Request
+
+Whenever the [store](./architecture.md#core---store) receives a command (e.g. apply, get, list) it needs to check if the subject trying to make the request has the necessary permissions to do so.
+This is defined in the `auth.Request` struct, which includes a `Subject`, `Verb` and `Object`.
+The request is asking: can the Subject perform Verb on the Object.
+
+It is the responsibility of the `auth` package to check a request and say whether the action is permitted or not.
+
+### Subjects
+
+At this time, the only supported Subject is a list of groups that a user belongs to (fetched via the OIDC provider).
+
+#### Special groups
+
+There are some special groups to consider:
+
+- `system:authenticated` is added to the UserInfo that comes from the session. You can use this group to target anyone who has logged in.
+
+### Verbs
+
+The supported verbs are:
+
+- `read`
+- `create`
+- `update`
+- `delete`
+- `run`
+- `*`
+
+### Objects
+
+## Roles and RoleBindings
diff --git a/examples/greetings/cmd/greetings.go b/examples/greetings/cmd/greetings.go
index 978f364..6866c6b 100644
--- a/examples/greetings/cmd/greetings.go
+++ b/examples/greetings/cmd/greetings.go
@@ -9,6 +9,7 @@ import (
"github.com/nats-io/nats.go"
"github.com/verifa/horizon/examples/greetings"
+ "github.com/verifa/horizon/pkg/controller"
"github.com/verifa/horizon/pkg/hz"
)
@@ -57,12 +58,12 @@ func run() error {
),
},
}
- ctlr, err := hz.StartController(
+ ctlr, err := controller.Start(
ctx,
conn,
- hz.WithControllerFor(greetings.Greeting{}),
- hz.WithControllerReconciler(&reconciler),
- hz.WithControllerValidator(&validator),
+ controller.WithFor(greetings.Greeting{}),
+ controller.WithReconciler(&reconciler),
+ controller.WithValidator(&validator),
)
if err != nil {
return fmt.Errorf("start controller: %w", err)
diff --git a/examples/greetings/greetings_test.go b/examples/greetings/greetings_test.go
index 718dd46..ba196dd 100644
--- a/examples/greetings/greetings_test.go
+++ b/examples/greetings/greetings_test.go
@@ -6,6 +6,7 @@ import (
"time"
"github.com/verifa/horizon/examples/greetings"
+ "github.com/verifa/horizon/pkg/controller"
"github.com/verifa/horizon/pkg/hz"
"github.com/verifa/horizon/pkg/hztest"
"github.com/verifa/horizon/pkg/server"
@@ -29,12 +30,12 @@ func TestGreeting(t *testing.T) {
recon := greetings.GreetingReconciler{
GreetingClient: greetClient,
}
- ctlr, err := hz.StartController(
+ ctlr, err := controller.Start(
ctx,
ts.Conn,
- hz.WithControllerFor(greetings.Greeting{}),
- hz.WithControllerValidator(&validr),
- hz.WithControllerReconciler(&recon),
+ controller.WithFor(greetings.Greeting{}),
+ controller.WithValidator(&validr),
+ controller.WithReconciler(&recon),
)
if err != nil {
t.Fatal("starting greeting controller: ", err)
diff --git a/examples/greetings/validator.go b/examples/greetings/validator.go
index 4897329..f04637d 100644
--- a/examples/greetings/validator.go
+++ b/examples/greetings/validator.go
@@ -12,7 +12,7 @@ import (
var _ (hz.Validator) = (*GreetingValidator)(nil)
type GreetingValidator struct {
- hz.ZeroValidator
+ hz.ValidateNothing
}
func (*GreetingValidator) ValidateCreate(
diff --git a/examples/services/.gitignore b/examples/services/.gitignore
new file mode 100644
index 0000000..cfb9095
--- /dev/null
+++ b/examples/services/.gitignore
@@ -0,0 +1 @@
+*_templ.go
diff --git a/examples/services/README.md b/examples/services/README.md
new file mode 100644
index 0000000..52fbe55
--- /dev/null
+++ b/examples/services/README.md
@@ -0,0 +1,15 @@
+# Extension Template
+
+This is a very basic example to get you started.
+
+It embeds the Horizon and NATS server, so you can make a single executable that also includes your extension.
+
+You can use [gonew](https://pkg.go.dev/golang.org/x/tools/cmd/gonew) to bootstrap your project:
+
+```console
+go install golang.org/x/tools/cmd/gonew@latest
+
+gonew github.com/verifa/horizon/examples/services your.domain/selfservice
+```
+
+**TODO: handle new namespace 403 error when logging in as `user-a`**
diff --git a/examples/services/cmd/horizon.go b/examples/services/cmd/horizon.go
new file mode 100644
index 0000000..aae1b16
--- /dev/null
+++ b/examples/services/cmd/horizon.go
@@ -0,0 +1,195 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "os"
+ "os/signal"
+
+ "github.com/nats-io/nats.go"
+ "github.com/verifa/horizon/examples/services/pkg/ext/services"
+ "github.com/verifa/horizon/pkg/auth"
+ "github.com/verifa/horizon/pkg/controller"
+ "github.com/verifa/horizon/pkg/extensions/core"
+ "github.com/verifa/horizon/pkg/gateway"
+ "github.com/verifa/horizon/pkg/hz"
+ "github.com/verifa/horizon/pkg/server"
+
+ cloudrun "google.golang.org/api/run/v1"
+)
+
+func main() {
+ if err := run(); err != nil {
+ slog.Error("running", "error", err)
+ os.Exit(1)
+ }
+}
+
+func run() error {
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
+ defer stop()
+ //
+ // Connect to Google Cloud Run API.
+ //
+ _, err := cloudrun.NewService(ctx)
+ if err != nil {
+ return fmt.Errorf("connect to Google Cloud Run API: %w", err)
+ }
+ //
+ // Start Horizon embedded server.
+ //
+ s, err := server.Start(
+ ctx,
+ server.WithDevMode(),
+ server.WithAuthOptions(auth.WithAdminGroups("admin")),
+ server.WithGatewayOptions(
+ gateway.WithOIDCConfig(gateway.OIDCConfig{
+ Issuer: "https://accounts.google.com",
+ ClientID: "50561335587-qp0rcctibj88mcpg13fh9ecq2mlb9faq.apps.googleusercontent.com",
+ ClientSecret: "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",
+ RedirectURL: "http://localhost:9999/auth/callback",
+ Scopes: []string{
+ "openid",
+ "profile",
+ "email",
+ },
+ }),
+ ),
+ )
+ if err != nil {
+ return err
+ }
+ defer s.Close()
+ slog.Info("horizon server started")
+
+ //
+ // Start Horizon extensions (controllers, portals, etc.).
+ //
+ validator := services.Validator{}
+ reconciler := services.Reconciler{
+ Client: hz.ObjectClient[services.Service]{
+ Client: hz.NewClient(
+ s.Conn,
+ hz.WithClientInternal(true),
+ hz.WithClientManager("ctlr-services"),
+ ),
+ },
+ }
+ ctlr, err := controller.Start(
+ ctx,
+ s.Conn,
+ controller.WithFor(services.Service{}),
+ controller.WithReconciler(&reconciler),
+ controller.WithValidator(&validator),
+ )
+ if err != nil {
+ return fmt.Errorf("start controller: %w", err)
+ }
+ defer func() {
+ _ = ctlr.Stop()
+ }()
+
+ portalHandler := services.PortalHandler{
+ Conn: s.Conn,
+ }
+ router := portalHandler.Router()
+ portal, err := hz.StartPortal(ctx, s.Conn, services.Portal, router)
+ if err != nil {
+ return fmt.Errorf("start portal: %w", err)
+ }
+ defer func() {
+ _ = portal.Stop()
+ }()
+
+ if err := setupDefaultRBAC(ctx, s.Conn); err != nil {
+ return fmt.Errorf("setup default RBAC: %w", err)
+ }
+
+ if err := createDemoService(ctx, s.Conn); err != nil {
+ return fmt.Errorf("create demo service: %w", err)
+ }
+
+ // Wait for interrupt signal.
+ <-ctx.Done()
+ // Stop listening for interrupts so that a second interrupt will force
+ // shutdown.
+ stop()
+ slog.Info(
+ "interrupt received, shutting down horizon server",
+ )
+ return nil
+}
+
+func setupDefaultRBAC(ctx context.Context, conn *nats.Conn) error {
+ client := hz.NewClient(conn, hz.WithClientInternal(true))
+ authenticatedRole := auth.Role{
+ ObjectMeta: hz.ObjectMeta{
+ Name: "authenticated-users-role",
+ Namespace: hz.NamespaceRoot,
+ },
+ Spec: auth.RoleSpec{
+ Allow: []auth.Rule{
+ {
+ Group: hz.P("*"),
+ Kind: hz.P("Namespace"),
+ Verbs: []auth.Verb{auth.VerbRead, auth.VerbCreate},
+ },
+ {
+ Group: hz.P("*"),
+ Kind: hz.P("Service"),
+ Verbs: []auth.Verb{auth.VerbRead, auth.VerbAll},
+ },
+ },
+ },
+ }
+ authenticatedRoleBinding := auth.RoleBinding{
+ ObjectMeta: hz.ObjectMeta{
+ Name: "authenticated-users-role-binding",
+ Namespace: hz.NamespaceRoot,
+ },
+ Spec: auth.RoleBindingSpec{
+ RoleRef: auth.RoleRefFromRole(authenticatedRole),
+ Subjects: []auth.Subject{
+ {
+ Kind: "Group",
+ Name: auth.GroupSystemAuthenticated,
+ },
+ },
+ },
+ }
+ if _, err := client.Apply(ctx, hz.WithApplyObject(authenticatedRole)); err != nil {
+ return fmt.Errorf("apply namespace role: %w", err)
+ }
+ if _, err := client.Apply(ctx, hz.WithApplyObject(authenticatedRoleBinding)); err != nil {
+ return fmt.Errorf("apply namespace role binding: %w", err)
+ }
+ return nil
+}
+
+func createDemoService(ctx context.Context, conn *nats.Conn) error {
+ client := hz.NewClient(conn, hz.WithClientInternal(true))
+ ns := core.Namespace{
+ ObjectMeta: hz.ObjectMeta{
+ Name: "demo",
+ Namespace: hz.NamespaceRoot,
+ },
+ }
+ if _, err := client.Apply(ctx, hz.WithApplyObject(ns)); err != nil {
+ return fmt.Errorf("apply namespace: %w", err)
+ }
+ service := services.Service{
+ ObjectMeta: hz.ObjectMeta{
+ Namespace: ns.Name,
+ Name: "demo-prod",
+ },
+ Spec: &services.ServiceSpec{
+ Host: hz.P("demo.horizon.xyz"),
+ Image: hz.P("horizon-demo:123456"),
+ },
+ }
+ if _, err := client.Apply(ctx, hz.WithApplyObject(service)); err != nil {
+ return fmt.Errorf("apply service: %w", err)
+ }
+ return nil
+}
diff --git a/examples/services/go.mod b/examples/services/go.mod
new file mode 100644
index 0000000..eb170df
--- /dev/null
+++ b/examples/services/go.mod
@@ -0,0 +1,71 @@
+module github.com/verifa/horizon/examples/services
+
+go 1.23.2
+
+replace github.com/verifa/horizon => ../..
+
+require (
+ github.com/a-h/templ v0.2.793
+ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc
+ github.com/go-chi/chi/v5 v5.0.12
+ github.com/go-chi/httplog/v2 v2.1.1
+ github.com/nats-io/nats.go v1.37.0
+ github.com/verifa/horizon v0.0.0-00010101000000-000000000000
+ golang.org/x/sync v0.9.0
+ google.golang.org/api v0.206.0
+)
+
+require (
+ cloud.google.com/go/auth v0.10.2 // indirect
+ cloud.google.com/go/auth/oauth2adapt v0.2.5 // indirect
+ cloud.google.com/go/compute/metadata v0.5.2 // indirect
+ cuelang.org/go v0.7.0 // indirect
+ github.com/bmatcuk/doublestar/v4 v4.6.1 // indirect
+ github.com/cockroachdb/apd/v3 v3.2.1 // indirect
+ github.com/coreos/go-oidc/v3 v3.9.0 // indirect
+ github.com/felixge/httpsnoop v1.0.4 // indirect
+ github.com/go-chi/chi v1.5.5 // indirect
+ github.com/go-jose/go-jose/v3 v3.0.1 // indirect
+ github.com/go-logr/logr v1.4.2 // indirect
+ github.com/go-logr/stdr v1.2.2 // indirect
+ github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
+ github.com/google/s2a-go v0.1.8 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
+ github.com/googleapis/gax-go/v2 v2.14.0 // indirect
+ github.com/gorilla/securecookie v1.1.2 // indirect
+ github.com/klauspost/compress v1.17.7 // indirect
+ github.com/minio/highwayhash v1.0.2 // indirect
+ github.com/mpvl/unique v0.0.0-20150818121801-cbe035fff7de // indirect
+ github.com/muhlemmer/gu v0.3.1 // indirect
+ github.com/muhlemmer/httpforwarded v0.1.0 // indirect
+ github.com/nats-io/jwt/v2 v2.5.5 // indirect
+ github.com/nats-io/nats-server/v2 v2.10.11 // indirect
+ github.com/nats-io/nkeys v0.4.7 // indirect
+ github.com/nats-io/nuid v1.0.1 // indirect
+ github.com/rs/cors v1.11.0 // indirect
+ github.com/sirupsen/logrus v1.9.3 // indirect
+ github.com/tidwall/gjson v1.17.0 // indirect
+ github.com/tidwall/match v1.1.1 // indirect
+ github.com/tidwall/pretty v1.2.0 // indirect
+ github.com/tidwall/sjson v1.2.5 // indirect
+ github.com/zitadel/logging v0.5.0 // indirect
+ github.com/zitadel/oidc/v3 v3.11.2 // indirect
+ github.com/zitadel/schema v1.3.0 // indirect
+ go.opencensus.io v0.24.0 // indirect
+ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect
+ go.opentelemetry.io/otel v1.29.0 // indirect
+ go.opentelemetry.io/otel/metric v1.29.0 // indirect
+ go.opentelemetry.io/otel/trace v1.29.0 // indirect
+ golang.org/x/crypto v0.29.0 // indirect
+ golang.org/x/exp v0.0.0-20240103183307-be819d1f06fc // indirect
+ golang.org/x/net v0.31.0 // indirect
+ golang.org/x/oauth2 v0.24.0 // indirect
+ golang.org/x/sys v0.27.0 // indirect
+ golang.org/x/text v0.20.0 // indirect
+ golang.org/x/time v0.8.0 // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20241104194629-dd2ea8efbc28 // indirect
+ google.golang.org/grpc v1.67.1 // indirect
+ google.golang.org/protobuf v1.35.1 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+)
diff --git a/examples/services/go.sum b/examples/services/go.sum
new file mode 100644
index 0000000..bf979ac
--- /dev/null
+++ b/examples/services/go.sum
@@ -0,0 +1,259 @@
+cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
+cloud.google.com/go/auth v0.10.2 h1:oKF7rgBfSHdp/kuhXtqU/tNDr0mZqhYbEh+6SiqzkKo=
+cloud.google.com/go/auth v0.10.2/go.mod h1:xxA5AqpDrvS+Gkmo9RqrGGRh6WSNKKOXhY3zNOr38tI=
+cloud.google.com/go/auth/oauth2adapt v0.2.5 h1:2p29+dePqsCHPP1bqDJcKj4qxRyYCcbzKpFyKGt3MTk=
+cloud.google.com/go/auth/oauth2adapt v0.2.5/go.mod h1:AlmsELtlEBnaNTL7jCj8VQFLy6mbZv0s4Q7NGBeQ5E8=
+cloud.google.com/go/compute/metadata v0.5.2 h1:UxK4uu/Tn+I3p2dYWTfiX4wva7aYlKixAHn3fyqngqo=
+cloud.google.com/go/compute/metadata v0.5.2/go.mod h1:C66sj2AluDcIqakBq/M8lw8/ybHgOZqin2obFxa/E5k=
+cuelabs.dev/go/oci/ociregistry v0.0.0-20231103182354-93e78c079a13 h1:zkiIe8AxZ/kDjqQN+mDKc5BxoVJOqioSdqApjc+eB1I=
+cuelabs.dev/go/oci/ociregistry v0.0.0-20231103182354-93e78c079a13/go.mod h1:XGKYSMtsJWfqQYPwq51ZygxAPqpEUj/9bdg16iDPTAA=
+cuelang.org/go v0.7.0 h1:gMztinxuKfJwMIxtboFsNc6s8AxwJGgsJV+3CuLffHI=
+cuelang.org/go v0.7.0/go.mod h1:ix+3dM/bSpdG9xg6qpCgnJnpeLtciZu+O/rDbywoMII=
+github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
+github.com/a-h/templ v0.2.793 h1:Io+/ocnfGWYO4VHdR0zBbf39PQlnzVCVVD+wEEs6/qY=
+github.com/a-h/templ v0.2.793/go.mod h1:lq48JXoUvuQrU0VThrK31yFwdRjTCnIE5bcPCM9IP1w=
+github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I=
+github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
+github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
+github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
+github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
+github.com/cockroachdb/apd/v3 v3.2.1 h1:U+8j7t0axsIgvQUqthuNm82HIrYXodOV2iWLWtEaIwg=
+github.com/cockroachdb/apd/v3 v3.2.1/go.mod h1:klXJcjp+FffLTHlhIG69tezTDvdP065naDsHzKhYSqc=
+github.com/coreos/go-oidc/v3 v3.9.0 h1:0J/ogVOd4y8P0f0xUh8l9t07xRP/d8tccvjHl2dcsSo=
+github.com/coreos/go-oidc/v3 v3.9.0/go.mod h1:rTKz2PYwftcrtoCzV5g5kvfJoWcm0Mk8AF8y1iAQro4=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/emicklei/proto v1.10.0 h1:pDGyFRVV5RvV+nkBK9iy3q67FBy9Xa7vwrOTE+g5aGw=
+github.com/emicklei/proto v1.10.0/go.mod h1:rn1FgRS/FANiZdD2djyH7TMA9jdRDcYQ9IEN9yvjX0A=
+github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
+github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
+github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
+github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
+github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
+github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
+github.com/go-chi/chi v1.5.5 h1:vOB/HbEMt9QqBqErz07QehcOKHaWFtuj87tTDVz2qXE=
+github.com/go-chi/chi v1.5.5/go.mod h1:C9JqLr3tIYjDOZpzn+BCuxY8z8vmca43EeMgyZt7irw=
+github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s=
+github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
+github.com/go-chi/httplog/v2 v2.1.1 h1:ojojiu4PIaoeJ/qAO4GWUxJqvYUTobeo7zmuHQJAxRk=
+github.com/go-chi/httplog/v2 v2.1.1/go.mod h1:/XXdxicJsp4BA5fapgIC3VuTD+z0Z/VzukoB3VDc1YE=
+github.com/go-jose/go-jose/v3 v3.0.1 h1:pWmKFVtt+Jl0vBZTIpz/eAKwsm6LkIxDVVbFHKkchhA=
+github.com/go-jose/go-jose/v3 v3.0.1/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8=
+github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
+github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
+github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
+github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
+github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
+github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
+github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
+github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
+github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
+github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
+github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
+github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
+github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
+github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
+github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
+github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
+github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
+github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
+github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
+github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
+github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA=
+github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw=
+github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
+github.com/googleapis/gax-go/v2 v2.14.0 h1:f+jMrjBPl+DL9nI4IQzLUxMq7XrAqFYB7hBPqMNIe8o=
+github.com/googleapis/gax-go/v2 v2.14.0/go.mod h1:lhBCnjdLrWRaPvLWhmc8IS24m9mr07qSYnHncrgo+zk=
+github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
+github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
+github.com/klauspost/compress v1.17.7 h1:ehO88t2UGzQK66LMdE8tibEd1ErmzZjNEqWkjLAKQQg=
+github.com/klauspost/compress v1.17.7/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/lib/pq v1.10.7 h1:p7ZhMD+KsSRozJr34udlUrhboJwWAgCg34+/ZZNvZZw=
+github.com/lib/pq v1.10.7/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
+github.com/minio/highwayhash v1.0.2 h1:Aak5U0nElisjDCfPSG79Tgzkn2gl66NxOMspRrKnA/g=
+github.com/minio/highwayhash v1.0.2/go.mod h1:BQskDq+xkJ12lmlUUi7U0M5Swg3EWR+dLTk+kldvVxY=
+github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=
+github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
+github.com/mpvl/unique v0.0.0-20150818121801-cbe035fff7de h1:D5x39vF5KCwKQaw+OC9ZPiLVHXz3UFw2+psEX+gYcto=
+github.com/mpvl/unique v0.0.0-20150818121801-cbe035fff7de/go.mod h1:kJun4WP5gFuHZgRjZUWWuH1DTxCtxbHDOIJsudS8jzY=
+github.com/muhlemmer/gu v0.3.1 h1:7EAqmFrW7n3hETvuAdmFmn4hS8W+z3LgKtrnow+YzNM=
+github.com/muhlemmer/gu v0.3.1/go.mod h1:YHtHR+gxM+bKEIIs7Hmi9sPT3ZDUvTN/i88wQpZkrdM=
+github.com/muhlemmer/httpforwarded v0.1.0 h1:x4DLrzXdliq8mprgUMR0olDvHGkou5BJsK/vWUetyzY=
+github.com/muhlemmer/httpforwarded v0.1.0/go.mod h1:yo9czKedo2pdZhoXe+yDkGVbU0TJ0q9oQ90BVoDEtw0=
+github.com/nats-io/jwt/v2 v2.5.5 h1:ROfXb50elFq5c9+1ztaUbdlrArNFl2+fQWP6B8HGEq4=
+github.com/nats-io/jwt/v2 v2.5.5/go.mod h1:ZdWS1nZa6WMZfFwwgpEaqBV8EPGVgOTDHN/wTbz0Y5A=
+github.com/nats-io/nats-server/v2 v2.10.11 h1:yKUiLVincZISpo3A4YljJQ+HfLltGAgoNNJl99KL8I0=
+github.com/nats-io/nats-server/v2 v2.10.11/go.mod h1:dXtOqVWzbMTEj+tUyC/itXjJhW37xh0tUBrTAlqAfx8=
+github.com/nats-io/nats.go v1.37.0 h1:07rauXbVnnJvv1gfIyghFEo6lUcYRY0WXc3x7x0vUxE=
+github.com/nats-io/nats.go v1.37.0/go.mod h1:Ubdu4Nh9exXdSz0RVWRFBbRfrbSxOYd26oF0wkWclB8=
+github.com/nats-io/nkeys v0.4.7 h1:RwNJbbIdYCoClSDNY7QVKZlyb/wfT6ugvFCiKy6vDvI=
+github.com/nats-io/nkeys v0.4.7/go.mod h1:kqXRgRDPlGy7nGaEDMuYzmiJCIAAWDK0IMBtDmGD0nc=
+github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
+github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
+github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
+github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
+github.com/opencontainers/image-spec v1.1.0-rc5 h1:Ygwkfw9bpDvs+c9E34SdgGOj41dX/cbdlwvlWt0pnFI=
+github.com/opencontainers/image-spec v1.1.0-rc5/go.mod h1:X4pATf0uXsnn3g5aiGIsVnJBR4mxhKzfwmvK/B2NTm8=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
+github.com/protocolbuffers/txtpbfmt v0.0.0-20230328191034-3462fbc510c0 h1:sadMIsgmHpEOGbUs6VtHBXRR1OHevnj7hLx9ZcdNGW4=
+github.com/protocolbuffers/txtpbfmt v0.0.0-20230328191034-3462fbc510c0/go.mod h1:jgxiZysxFPM+iWKwQwPR+y+Jvo54ARd4EisXxKYpB5c=
+github.com/rogpeppe/go-internal v1.11.1-0.20231026093722-fa6a31e0812c h1:fPpdjePK1atuOg28PXfNSqgwf9I/qD1Hlo39JFwKBXk=
+github.com/rogpeppe/go-internal v1.11.1-0.20231026093722-fa6a31e0812c/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
+github.com/rs/cors v1.11.0 h1:0B9GE/r9Bc2UxRMMtymBkHTenPkHDv0CW4Y98GBY+po=
+github.com/rs/cors v1.11.0/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
+github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
+github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
+github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
+github.com/tidwall/gjson v1.17.0 h1:/Jocvlh98kcTfpN2+JzGQWQcqrPQwDrVEMApx/M5ZwM=
+github.com/tidwall/gjson v1.17.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
+github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
+github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
+github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
+github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
+github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
+github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
+github.com/zitadel/logging v0.5.0 h1:Kunouvqse/efXy4UDvFw5s3vP+Z4AlHo3y8wF7stXHA=
+github.com/zitadel/logging v0.5.0/go.mod h1:IzP5fzwFhzzyxHkSmfF8dsyqFsQRJLLcQmwhIBzlGsE=
+github.com/zitadel/oidc/v3 v3.11.2 h1:NRccgJKGsrHe3bVjV3Ric7VtVoLscu1YwQVxyehebqY=
+github.com/zitadel/oidc/v3 v3.11.2/go.mod h1:mFCrFvb6KA9A4gZisSXI+0T1zz9z09OmjQ804kJD/KU=
+github.com/zitadel/schema v1.3.0 h1:kQ9W9tvIwZICCKWcMvCEweXET1OcOyGEuFbHs4o5kg0=
+github.com/zitadel/schema v1.3.0/go.mod h1:NptN6mkBDFvERUCvZHlvWmmME+gmZ44xzwRXwhzsbtc=
+go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
+go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
+go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
+go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
+go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc=
+go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8=
+go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
+go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ=
+golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg=
+golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
+golang.org/x/exp v0.0.0-20240103183307-be819d1f06fc h1:ao2WRsKSzW6KuUY9IWPwWahcHCgR0s52IfwutMfEbdM=
+golang.org/x/exp v0.0.0-20240103183307-be819d1f06fc/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI=
+golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
+golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
+golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/mod v0.20.0 h1:utOm6MM3R3dnawAiJgn0y+xvuYRsm1RKM/4giyfDgV0=
+golang.org/x/mod v0.20.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
+golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo=
+golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM=
+golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
+golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE=
+golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
+golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ=
+golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190130150945-aca44879d564/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
+golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
+golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
+golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
+golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
+golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
+golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24=
+golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+google.golang.org/api v0.206.0 h1:A27GClesCSheW5P2BymVHjpEeQ2XHH8DI8Srs2HI2L8=
+google.golang.org/api v0.206.0/go.mod h1:BtB8bfjTYIrai3d8UyvPmV9REGgox7coh+ZRwm0b+W8=
+google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
+google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
+google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
+google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
+google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
+google.golang.org/genproto v0.0.0-20241104194629-dd2ea8efbc28 h1:KJjNNclfpIkVqrZlTWcgOOaVQ00LdBnoEaRfkUx760s=
+google.golang.org/genproto/googleapis/api v0.0.0-20241104194629-dd2ea8efbc28 h1:M0KvPgPmDZHPlbRbaNU1APr28TvwvvdUPlSv7PUvy8g=
+google.golang.org/genproto/googleapis/api v0.0.0-20241104194629-dd2ea8efbc28/go.mod h1:dguCy7UOdZhTvLzDyt15+rOrawrpM4q7DD9dQ1P11P4=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20241104194629-dd2ea8efbc28 h1:XVhgTWWV3kGQlwJHR3upFWZeTsei6Oks1apkZSeonIE=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20241104194629-dd2ea8efbc28/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI=
+google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
+google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
+google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
+google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
+google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
+google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E=
+google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA=
+google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
+google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
+google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
+google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
+google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
+google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
+google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
+google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
+google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
+google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA=
+google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E=
+sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY=
diff --git a/examples/services/pkg/ext/services/cloudrun_test.go b/examples/services/pkg/ext/services/cloudrun_test.go
new file mode 100644
index 0000000..b25dd05
--- /dev/null
+++ b/examples/services/pkg/ext/services/cloudrun_test.go
@@ -0,0 +1,48 @@
+package services_test
+
+import (
+ "context"
+ "fmt"
+ "testing"
+
+ "github.com/davecgh/go-spew/spew"
+ "google.golang.org/api/option"
+ cloudrun "google.golang.org/api/run/v1"
+)
+
+func TestCludRun(t *testing.T) {
+ err := do()
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+
+func do() error {
+ ctx := context.Background()
+ region := "europe-north1"
+ runService, err := cloudrun.NewService(
+ ctx,
+ option.WithEndpoint(
+ fmt.Sprintf("https://%s-run.googleapis.com", region),
+ ),
+ )
+ if err != nil {
+ return fmt.Errorf("connect to Google Cloud Run API: %w", err)
+ }
+ serviceURI := fmt.Sprintf(
+ "namespaces/%s/services/%s",
+ "verifa-website",
+ "prod-website-service",
+ // europe-north1/prod-website-service
+ )
+ svc, err := runService.Namespaces.Services.Get(serviceURI).
+ Context(ctx).
+ Do()
+ if err != nil {
+ return fmt.Errorf("get service: %w", err)
+ }
+ // TODO: check conditions.
+ // svc.Status.Conditions
+ spew.Dump(svc)
+ return nil
+}
diff --git a/examples/services/pkg/ext/services/portal.go b/examples/services/pkg/ext/services/portal.go
new file mode 100644
index 0000000..24a8883
--- /dev/null
+++ b/examples/services/pkg/ext/services/portal.go
@@ -0,0 +1,144 @@
+package services
+
+import (
+ "fmt"
+ "log/slog"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/go-chi/chi/v5"
+ "github.com/go-chi/httplog/v2"
+ "github.com/nats-io/nats.go"
+ "github.com/verifa/horizon/pkg/gateway"
+ "github.com/verifa/horizon/pkg/hz"
+ cloudrun "google.golang.org/api/run/v1"
+)
+
+const extName = "services"
+
+var Portal = hz.Portal{
+ ObjectMeta: hz.ObjectMeta{
+ Namespace: hz.NamespaceRoot,
+ Name: extName,
+ },
+ Spec: &hz.PortalSpec{
+ DisplayName: "Services",
+ Icon: gateway.IconRectangleStack,
+ },
+}
+
+type PortalHandler struct {
+ Conn *nats.Conn
+ CloudRunClient *cloudrun.APIService
+}
+
+func (h *PortalHandler) Router() *chi.Mux {
+ r := chi.NewRouter()
+ logger := httplog.NewLogger("portal-services", httplog.Options{
+ JSON: false,
+ LogLevel: slog.LevelInfo,
+ Concise: true,
+ RequestHeaders: true,
+ MessageFieldName: "message",
+ QuietDownRoutes: []string{
+ "/",
+ "/ping",
+ },
+ QuietDownPeriod: 10 * time.Second,
+ })
+ r.Use(httplog.RequestLogger(logger))
+ r.Get("/", h.get)
+ r.Post("/", h.post)
+ return r
+}
+
+func (h *PortalHandler) get(rw http.ResponseWriter, req *http.Request) {
+ if req.Header.Get("HX-Request") == "" {
+ rendr := PortalRenderer{
+ Namespace: req.Header.Get(hz.RequestNamespace),
+ Portal: req.Header.Get(hz.RequestPortal),
+ }
+ _ = rendr.home().Render(req.Context(), rw)
+ return
+ }
+ h.tableBody(rw, req)
+}
+
+func (h *PortalHandler) post(rw http.ResponseWriter, req *http.Request) {
+ rendr := PortalRenderer{
+ Namespace: req.Header.Get(hz.RequestNamespace),
+ Portal: req.Header.Get(hz.RequestPortal),
+ }
+ if err := req.ParseForm(); err != nil {
+ _ = rendr.form(
+ "",
+ fmt.Errorf("error parsing form: %w", err),
+ ).Render(req.Context(), rw)
+ return
+ }
+
+ reqName := req.PostForm.Get("name")
+
+ service := Service{
+ ObjectMeta: hz.ObjectMeta{
+ Namespace: req.Header.Get(hz.RequestNamespace),
+ Name: reqName,
+ },
+ Spec: &ServiceSpec{},
+ }
+
+ client := hz.ObjectClient[Service]{Client: hz.NewClient(
+ h.Conn,
+ hz.WithClientSessionFromRequest(req),
+ )}
+ if _, err := client.Apply(req.Context(), service, hz.WithApplyCreateOnly(true)); err != nil {
+ _ = rendr.form(reqName, err).
+ Render(req.Context(), rw)
+ return
+ }
+ rw.WriteHeader(http.StatusCreated)
+ rw.Header().Add("HX-Trigger", "newService")
+ _ = rendr.form("", nil).Render(req.Context(), rw)
+}
+
+func (h *PortalHandler) tableBody(rw http.ResponseWriter, req *http.Request) {
+ rendr := PortalRenderer{
+ Namespace: req.Header.Get(hz.RequestNamespace),
+ Portal: req.Header.Get(hz.RequestPortal),
+ }
+ client := hz.ObjectClient[Service]{
+ Client: hz.NewClient(h.Conn, hz.WithClientSessionFromRequest(req)),
+ }
+ services, err := client.List(
+ req.Context(),
+ hz.WithListKey(hz.ObjectKey{
+ Namespace: req.Header.Get(hz.RequestNamespace),
+ }),
+ )
+ if err != nil {
+ http.Error(rw, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ fmt.Println("SERVICES: ", services)
+
+ _ = rendr.tableBody(services).Render(req.Context(), rw)
+}
+
+func isServiceReady(status *ServiceStatus) bool {
+ if status == nil {
+ return false
+ }
+ return status.Ready
+}
+
+type PortalRenderer struct {
+ Namespace string
+ Portal string
+}
+
+func (r *PortalRenderer) URL(steps ...string) string {
+ base := fmt.Sprintf("/namespaces/%s/portal/%s", r.Namespace, r.Portal)
+ path := append([]string{base}, steps...)
+ return strings.Join(path, "/")
+}
diff --git a/examples/services/pkg/ext/services/portal.templ b/examples/services/pkg/ext/services/portal.templ
new file mode 100644
index 0000000..b53735b
--- /dev/null
+++ b/examples/services/pkg/ext/services/portal.templ
@@ -0,0 +1,49 @@
+package services
+
+import "github.com/verifa/horizon/pkg/hz"
+
+templ (r *PortalRenderer) home() {
+
+
Services
+
+
+
+ Name |
+ Ready |
+
+
+
+
+
+}
+
+templ (r *PortalRenderer) form(name string, err error) {
+
+}
+
+templ (r *PortalRenderer) tableBody(services []Service) {
+ if len(services) == 0 {
+
+ No services yet |
+
+ }
+ for _, service := range services {
+
+ { service.ObjectMeta.Name } |
+ if isServiceReady(service.Status) {
+ Ready |
+ } else {
+ Not Ready |
+ }
+
+ }
+}
diff --git a/examples/services/pkg/ext/services/reconciler.go b/examples/services/pkg/ext/services/reconciler.go
new file mode 100644
index 0000000..6aa46b2
--- /dev/null
+++ b/examples/services/pkg/ext/services/reconciler.go
@@ -0,0 +1,47 @@
+package services
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/verifa/horizon/pkg/hz"
+)
+
+type Reconciler struct {
+ Client hz.ObjectClient[Service]
+}
+
+// Reconcile implements hz.Reconciler.
+func (r *Reconciler) Reconcile(
+ ctx context.Context,
+ req hz.Request,
+) (hz.Result, error) {
+ service, err := r.Client.Get(ctx, hz.WithGetKey(req.Key))
+ if err != nil {
+ return hz.Result{}, hz.IgnoreNotFound(err)
+ }
+ applyService, err := hz.ExtractManagedFields(
+ service,
+ r.Client.Client.Manager,
+ )
+ if err != nil {
+ return hz.Result{}, fmt.Errorf("extracting managed fields: %w", err)
+ }
+ if service.DeletionTimestamp.IsPast() {
+ // Handle any cleanup logic here.
+ return hz.Result{}, nil
+ }
+ // TODO: Implement the reconcile logic here.
+ // For example, call Terraform, deploy some Kubernetes stuff or use Go SDKs
+ // to create cloud resources.
+ //
+ // For now, just set the status as ready.
+ applyService.Status = &ServiceStatus{
+ Ready: true,
+ }
+ if _, err := r.Client.Apply(ctx, applyService); err != nil {
+ return hz.Result{}, fmt.Errorf("updating counter: %w", err)
+ }
+
+ return hz.Result{}, nil
+}
diff --git a/examples/services/pkg/ext/services/service.go b/examples/services/pkg/ext/services/service.go
new file mode 100644
index 0000000..12d8b34
--- /dev/null
+++ b/examples/services/pkg/ext/services/service.go
@@ -0,0 +1,57 @@
+package services
+
+import "github.com/verifa/horizon/pkg/hz"
+
+var _ hz.Objecter = (*Service)(nil)
+
+type Service struct {
+ hz.ObjectMeta `json:"metadata" cue:""`
+
+ Spec *ServiceSpec `json:"spec,omitempty" cue:""`
+ Status *ServiceStatus `json:"status,omitempty" cue:",opt"`
+}
+
+func (s Service) ObjectGroup() string {
+ return "services"
+}
+
+func (s Service) ObjectVersion() string {
+ return "v1"
+}
+
+func (s Service) ObjectKind() string {
+ return "Service"
+}
+
+// ServiceSpec defines the desired state (i.e. inputs).
+type ServiceSpec struct {
+ // Host is the fully qualified domain name of the service.
+ Host *string `json:"host" cue:""`
+ // Image is the container image to run.
+ Image *string `json:"image,omitempty" cue:""`
+ // Command is the command to run in the container.
+ Command []string `json:"command,omitempty" cue:",opt"`
+ // Args is the arguments to the command.
+ Args []string `json:"args,omitempty" cue:",opt"`
+ // Env is the environment variables to set in the container.
+ Env map[string]string `json:"env,omitempty" cue:",opt"`
+ // Resources defines the resource requirements (requests and limits).
+ Resources *Resources `json:"resources,omitempty" cue:",opt"`
+}
+
+type Resources struct {
+ Requests ResourceQuantity `json:"requests,omitempty" cue:",opt"`
+ Limits ResourceQuantity `json:"limits,omitempty" cue:",opt"`
+}
+
+type ResourceQuantity struct {
+ CPU string `json:"cpu,omitempty" cue:",opt"`
+ Memory string `json:"memory,omitempty" cue:",opt"`
+}
+
+// ServiceStatus defines the observed state (i.e. outputs as set by the
+// controller).
+type ServiceStatus struct {
+ Ready bool `json:"ready,omitempty" cue:",opt"`
+ Error *string `json:"error,omitempty" cue:",opt"`
+}
diff --git a/examples/services/pkg/ext/services/service_test.go b/examples/services/pkg/ext/services/service_test.go
new file mode 100644
index 0000000..023d028
--- /dev/null
+++ b/examples/services/pkg/ext/services/service_test.go
@@ -0,0 +1,82 @@
+package services_test
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/verifa/horizon/examples/services/pkg/ext/services"
+ "github.com/verifa/horizon/pkg/controller"
+ "github.com/verifa/horizon/pkg/hz"
+ "github.com/verifa/horizon/pkg/hztest"
+ "github.com/verifa/horizon/pkg/server"
+)
+
+func TestService(t *testing.T) {
+ ctx := context.Background()
+ // Create a test server which includes the core of Horizon.
+ ts := server.Test(t, ctx)
+ client := hz.NewClient(
+ ts.Conn,
+ hz.WithClientInternal(true),
+ hz.WithClientManager("ctlr-counter"),
+ )
+ serviceClient := hz.ObjectClient[services.Service]{Client: client}
+
+ //
+ // Setup counter controller with validator and reconciler.
+ //
+ validr := services.Validator{}
+ recon := services.Reconciler{
+ Client: serviceClient,
+ }
+ ctlr, err := controller.Start(
+ ctx,
+ ts.Conn,
+ controller.WithFor(services.Service{}),
+ controller.WithValidator(&validr),
+ controller.WithReconciler(&recon),
+ )
+ if err != nil {
+ t.Fatal("starting service controller: ", err)
+ }
+ defer ctlr.Stop()
+
+ //
+ // Apply a counter object.
+ //
+ service := services.Service{
+ ObjectMeta: hz.ObjectMeta{
+ Namespace: "test",
+ Name: "test",
+ },
+ Spec: &services.ServiceSpec{
+ Host: hz.P("test.horizon.verifa.io"),
+ Image: hz.P("nginx"),
+ },
+ }
+ _, err = serviceClient.Apply(ctx, service)
+ if err != nil {
+ t.Fatal("applying service: ", err)
+ }
+
+ //
+ // Verify that the controller reconciles the object.
+ //
+ // Watch until the service is ready.
+ // If the timeout is reached, the test fails.
+ //
+ hztest.WatchWaitUntil(
+ t,
+ ctx,
+ ts.Conn,
+ time.Second*5,
+ service,
+ func(service services.Service) bool {
+ if service.Status == nil {
+ return false
+ }
+ return service.Status.Ready
+ },
+ )
+}
diff --git a/examples/services/pkg/ext/services/validator.go b/examples/services/pkg/ext/services/validator.go
new file mode 100644
index 0000000..b08e8fc
--- /dev/null
+++ b/examples/services/pkg/ext/services/validator.go
@@ -0,0 +1,21 @@
+package services
+
+import (
+ "context"
+
+ "github.com/verifa/horizon/pkg/hz"
+)
+
+var _ (hz.Validator) = (*Validator)(nil)
+
+type Validator struct {
+ hz.ValidateNothing
+}
+
+func (*Validator) ValidateCreate(
+ ctx context.Context,
+ data []byte,
+) error {
+ // Implement any custom validation logic here.
+ return nil
+}
diff --git a/pkg/auth/auth.go b/pkg/auth/auth.go
index 9ed076b..8dc68c8 100644
--- a/pkg/auth/auth.go
+++ b/pkg/auth/auth.go
@@ -9,6 +9,7 @@ import (
"log/slog"
"github.com/nats-io/nats.go"
+ "github.com/verifa/horizon/pkg/controller"
"github.com/verifa/horizon/pkg/hz"
)
@@ -47,7 +48,7 @@ type Auth struct {
Sessions *Sessions
RBAC *RBAC
- controllers []*hz.Controller
+ controllers []*controller.Controller
}
func (a *Auth) Start(
@@ -62,20 +63,20 @@ func (a *Auth) Start(
//
// Start controllers.
//
- ctlrRole, err := hz.StartController(
+ ctlrRole, err := controller.Start(
ctx,
a.Conn,
- hz.WithControllerFor(&Role{}),
+ controller.WithFor(&Role{}),
)
if err != nil {
return fmt.Errorf("starting role controller: %w", err)
}
a.controllers = append(a.controllers, ctlrRole)
- ctlrRoleBinding, err := hz.StartController(
+ ctlrRoleBinding, err := controller.Start(
ctx,
a.Conn,
- hz.WithControllerFor(&RoleBinding{}),
+ controller.WithFor(&RoleBinding{}),
)
if err != nil {
return fmt.Errorf("starting rolebinding controller: %w", err)
diff --git a/pkg/auth/rbac.go b/pkg/auth/rbac.go
index 142e5b6..aeaba45 100644
--- a/pkg/auth/rbac.go
+++ b/pkg/auth/rbac.go
@@ -45,9 +45,9 @@ func (r *RBAC) Start(ctx context.Context) error {
var err error
switch event.Key.ObjectKind() {
case "Role":
- result, err = r.HandleRoleEvent(event)
+ result, err = r.handleRoleEvent(event)
case "RoleBinding":
- result, err = r.HandleRoleBindingEvent(event)
+ result, err = r.handleRoleBindingEvent(event)
default:
err = fmt.Errorf(
"unexpected object kind: %v",
@@ -273,7 +273,7 @@ func checkStringPattern(pattern *string, value string) bool {
return true
}
-func (r *RBAC) HandleRoleBindingEvent(event hz.Event) (hz.Result, error) {
+func (r *RBAC) handleRoleBindingEvent(event hz.Event) (hz.Result, error) {
var rb RoleBinding
if err := json.Unmarshal(event.Data, &rb); err != nil {
return hz.Result{}, fmt.Errorf("unmarshalling role binding: %w", err)
@@ -298,7 +298,7 @@ func (r *RBAC) HandleRoleBindingEvent(event hz.Event) (hz.Result, error) {
return hz.Result{}, nil
}
-func (r *RBAC) HandleRoleEvent(event hz.Event) (hz.Result, error) {
+func (r *RBAC) handleRoleEvent(event hz.Event) (hz.Result, error) {
var role Role
if err := json.Unmarshal(event.Data, &role); err != nil {
return hz.Result{}, fmt.Errorf("unmarshalling role: %w", err)
diff --git a/pkg/auth/rbac_test.go b/pkg/auth/rbac_test.go
index d684a99..3669db7 100644
--- a/pkg/auth/rbac_test.go
+++ b/pkg/auth/rbac_test.go
@@ -453,11 +453,11 @@ func TestRBAC(t *testing.T) {
AdminGroup: test.adminGroup,
}
for _, role := range test.roles {
- _, err := rbac.HandleRoleEvent(event(t, role))
+ _, err := rbac.handleRoleEvent(event(t, role))
testutil.AssertNoError(t, err)
}
for _, binding := range test.bindings {
- _, err := rbac.HandleRoleBindingEvent(event(t, binding))
+ _, err := rbac.handleRoleBindingEvent(event(t, binding))
testutil.AssertNoError(t, err)
}
// Call refresh in case of no roles or rolebindings.
diff --git a/pkg/hz/controller.go b/pkg/controller/controller.go
similarity index 81%
rename from pkg/hz/controller.go
rename to pkg/controller/controller.go
index 0974a12..900212d 100644
--- a/pkg/hz/controller.go
+++ b/pkg/controller/controller.go
@@ -1,4 +1,4 @@
-package hz
+package controller
import (
"context"
@@ -8,7 +8,6 @@ import (
"log/slog"
"math"
"net/http"
- "reflect"
"runtime/debug"
"strings"
"sync"
@@ -16,56 +15,54 @@ import (
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
+ "github.com/verifa/horizon/pkg/extensions/core"
+ "github.com/verifa/horizon/pkg/hz"
+ "github.com/verifa/horizon/pkg/internal/openapiv3"
)
-type ControllerOption func(*controllerOption)
+type Option func(*controllerOption)
-func WithControllerBucket(bucketObjects string) ControllerOption {
+func WithBucket(bucketObjects string) Option {
return func(ro *controllerOption) {
ro.bucketObjects = bucketObjects
}
}
-func WithControllerReconciler(reconciler Reconciler) ControllerOption {
+func WithReconciler(reconciler hz.Reconciler) Option {
return func(ro *controllerOption) {
ro.reconciler = reconciler
}
}
-func WithControllerValidator(validator Validator) ControllerOption {
+func WithValidator(validator hz.Validator) Option {
return func(ro *controllerOption) {
ro.validators = append(ro.validators, validator)
}
}
-func WithControllerValidatorCUE(b bool) ControllerOption {
+func WithValidatorCUE(b bool) Option {
return func(ro *controllerOption) {
ro.cueValidator = b
}
}
-// WithControllerValidatorForceNone forces the controller to accept no
-// validators. It is intended for testing purposes. It is highly recommended to
-// use a validator to ensure data quality.
-func WithControllerValidatorForceNone() ControllerOption {
- return func(ro *controllerOption) {
- ro.validatorForceNone = true
- }
-}
-
-func WithControllerFor(obj Objecter) ControllerOption {
+// WithFor sets the object for which the controller is running for.
+// A controller can only reconcile one object.
+func WithFor(obj hz.Objecter) Option {
return func(ro *controllerOption) {
ro.forObject = obj
}
}
-func WithControllerOwns(obj Objecter) ControllerOption {
+// WithOwns sets objects that should be watched and checked if they are owned by
+// the object given with [WithFor].
+func WithOwns(obj hz.Objecter) Option {
return func(ro *controllerOption) {
ro.reconOwns = append(ro.reconOwns, obj)
}
}
-func WithControllerStopTimeout(d time.Duration) ControllerOption {
+func WithStopTimeout(d time.Duration) Option {
return func(ro *controllerOption) {
ro.stopTimeout = d
}
@@ -75,28 +72,21 @@ type controllerOption struct {
bucketObjects string
bucketMutex string
- reconciler Reconciler
- validators []Validator
- cueValidator bool
- validatorForceNone bool
+ reconciler hz.Reconciler
+ validators []hz.Validator
+ cueValidator bool
+ // validatorForceNone bool
- forObject Objecter
- reconOwns []Objecter
+ forObject hz.Objecter
+ reconOwns []hz.Objecter
stopTimeout time.Duration
}
-var controllerOptionsDefault = controllerOption{
- bucketObjects: BucketObjects,
- bucketMutex: BucketMutex,
- cueValidator: true,
- stopTimeout: time.Minute * 10,
-}
-
-func StartController(
+func Start(
ctx context.Context,
nc *nats.Conn,
- opts ...ControllerOption,
+ opts ...Option,
) (*Controller, error) {
ctlr := Controller{
Conn: nc,
@@ -120,9 +110,14 @@ type Controller struct {
func (c *Controller) Start(
ctx context.Context,
- opts ...ControllerOption,
+ opts ...Option,
) error {
- ro := controllerOptionsDefault
+ ro := controllerOption{
+ bucketObjects: hz.BucketObjects,
+ bucketMutex: hz.BucketMutex,
+ cueValidator: true,
+ stopTimeout: time.Minute * 10,
+ }
for _, opt := range opts {
opt(&ro)
}
@@ -131,33 +126,25 @@ func (c *Controller) Start(
}
c.stopTimeout = ro.stopTimeout
- // Check the forObject value is not a pointer, as this causes problems for
- // the cue encoder. If it is a pointer, get its element.
- if reflect.ValueOf(ro.forObject).Type().Kind() == reflect.Ptr {
- var ok bool
- ro.forObject, ok = reflect.ValueOf(ro.forObject).
- Elem().
- Interface().(Objecter)
- if !ok {
- return fmt.Errorf("getting element from object pointer")
- }
- }
if ro.bucketMutex == "" {
ro.bucketMutex = ro.bucketObjects + "_mutex"
}
if ro.cueValidator {
// Add the cue validator for the object.
- cueValidator := &CUEValidator{
+ cueValidator := &hz.ValidateCUE{
Object: ro.forObject,
}
if err := cueValidator.ParseObject(); err != nil {
return fmt.Errorf("parsing object: %w", err)
}
// Make sure the default validator comes first.
- ro.validators = append([]Validator{cueValidator}, ro.validators...)
+ ro.validators = append([]hz.Validator{cueValidator}, ro.validators...)
}
- if err := c.startSchema(ctx, ro); err != nil {
- return fmt.Errorf("start schema: %w", err)
+ // if err := c.startSchema(ctx, ro); err != nil {
+ // return fmt.Errorf("start schema: %w", err)
+ // }
+ if err := c.applyCustomResourceDefinition(ctx, ro); err != nil {
+ return fmt.Errorf("apply custom resource definition: %w", err)
}
if err := c.startValidators(ctx, ro); err != nil {
@@ -171,25 +158,81 @@ func (c *Controller) Start(
return nil
}
+func (c *Controller) applyCustomResourceDefinition(
+ ctx context.Context,
+ opt controllerOption,
+) error {
+ var (
+ schema *openapiv3.Schema
+ err error
+ )
+ if oapiv3, ok := opt.forObject.(hz.ObjectOpenAPIV3Schemer); ok {
+ schema, err = oapiv3.OpenAPIV3Schema()
+ } else {
+ schema, err = OpenAPIV3SchemaFromObject(opt.forObject)
+ }
+ if err != nil {
+ return fmt.Errorf("getting object schema: %w", err)
+ }
+
+ name := fmt.Sprintf(
+ "%s-%s-%s",
+ opt.forObject.ObjectGroup(),
+ opt.forObject.ObjectVersion(),
+ opt.forObject.ObjectKind(),
+ )
+ singularName := strings.ToLower(opt.forObject.ObjectKind())
+ crd := core.CustomResourceDefinition{
+ ObjectMeta: hz.ObjectMeta{
+ Name: name,
+ Namespace: hz.NamespaceRoot,
+ },
+ Spec: &core.CustomResourceDefinitionSpec{
+ Group: hz.P(opt.forObject.ObjectGroup()),
+ Version: hz.P(opt.forObject.ObjectVersion()),
+ Names: &core.CustomResourceDefinitionNames{
+ Kind: hz.P(opt.forObject.ObjectKind()),
+ Singular: &singularName,
+ },
+ Schema: &core.CustomResourceDefinitionSchema{
+ OpenAPIV3Schema: schema,
+ },
+ },
+ }
+
+ client := hz.NewClient(c.Conn, hz.WithClientInternal(true))
+ _, err = client.Apply(ctx, hz.WithApplyObject(crd))
+ if err != nil {
+ return fmt.Errorf("applying custom resource definition: %w", err)
+ }
+
+ return nil
+}
+
func (c *Controller) startSchema(
_ context.Context,
opt controllerOption,
) error {
obj := opt.forObject
- objSpec, err := OpenAPISpecFromObject(obj)
- if err != nil {
- return fmt.Errorf("getting object spec: %w", err)
+ var (
+ schema *openapiv3.Schema
+ err error
+ )
+
+ if oapiv3, ok := opt.forObject.(hz.ObjectOpenAPIV3Schemer); ok {
+ schema, err = oapiv3.OpenAPIV3Schema()
+ } else {
+ schema, err = OpenAPIV3SchemaFromObject(obj)
}
- schema, err := objSpec.Schema()
if err != nil {
- return fmt.Errorf("getting schema: %w", err)
+ return fmt.Errorf("getting object schema: %w", err)
}
bSchema, err := json.Marshal(schema)
if err != nil {
return fmt.Errorf("marshalling schema: %w", err)
}
subject := fmt.Sprintf(
- SubjectCtlrSchema,
+ hz.SubjectCtlrSchema,
obj.ObjectGroup(),
obj.ObjectVersion(),
obj.ObjectKind(),
@@ -215,7 +258,7 @@ func (c *Controller) startValidators(
obj := opt.forObject
{
subject := fmt.Sprintf(
- SubjectCtlrValidateCreate,
+ hz.SubjectCtlrValidateCreate,
obj.ObjectGroup(),
obj.ObjectVersion(),
obj.ObjectKind(),
@@ -234,7 +277,7 @@ func (c *Controller) startValidators(
}
{
subject := fmt.Sprintf(
- SubjectCtlrValidateUpdate,
+ hz.SubjectCtlrValidateUpdate,
obj.ObjectGroup(),
obj.ObjectVersion(),
obj.ObjectKind(),
@@ -259,10 +302,10 @@ func (c *Controller) handleValidateCreate(
opt controllerOption,
msg *nats.Msg,
) {
- var vErr *Error
+ var vErr *hz.Error
for _, validator := range opt.validators {
if err := validator.ValidateCreate(ctx, msg.Data); err != nil {
- vErr = &Error{
+ vErr = &hz.Error{
Status: http.StatusBadRequest,
Message: err.Error(),
}
@@ -271,10 +314,10 @@ func (c *Controller) handleValidateCreate(
}
}
if vErr != nil {
- _ = RespondError(msg, vErr)
+ _ = hz.RespondError(msg, vErr)
return
}
- _ = RespondOK(msg, nil)
+ _ = hz.RespondOK(msg, nil)
}
func (c *Controller) handleValidateUpdate(
@@ -282,9 +325,9 @@ func (c *Controller) handleValidateUpdate(
opt controllerOption,
msg *nats.Msg,
) {
- var metaObj MetaOnlyObject
+ var metaObj hz.MetaOnlyObject
if err := json.Unmarshal(msg.Data, &metaObj); err != nil {
- _ = RespondError(msg, &Error{
+ _ = hz.RespondError(msg, &hz.Error{
Status: http.StatusBadRequest,
Message: fmt.Sprintf(
"unmarshalling object: %s",
@@ -294,10 +337,10 @@ func (c *Controller) handleValidateUpdate(
return
}
// Need to fetch the existing object and pass it to the validators.
- client := NewClient(c.Conn, WithClientInternal(true))
- old, err := client.Get(ctx, WithGetKey(metaObj))
+ client := hz.NewClient(c.Conn, hz.WithClientInternal(true))
+ old, err := client.Get(ctx, hz.WithGetKey(metaObj))
if err != nil {
- _ = RespondError(msg, &Error{
+ _ = hz.RespondError(msg, &hz.Error{
Status: http.StatusInternalServerError,
Message: fmt.Sprintf(
"getting existing object from store: %s",
@@ -306,10 +349,10 @@ func (c *Controller) handleValidateUpdate(
})
return
}
- var vErr *Error
+ var vErr *hz.Error
for _, validator := range opt.validators {
if err := validator.ValidateUpdate(ctx, old, msg.Data); err != nil {
- vErr = &Error{
+ vErr = &hz.Error{
Status: http.StatusBadRequest,
Message: err.Error(),
}
@@ -318,10 +361,10 @@ func (c *Controller) handleValidateUpdate(
}
}
if vErr != nil {
- _ = RespondError(msg, vErr)
+ _ = hz.RespondError(msg, vErr)
return
}
- _ = RespondOK(msg, nil)
+ _ = hz.RespondOK(msg, nil)
}
func (c *Controller) startReconciler(
@@ -352,7 +395,7 @@ func (c *Controller) startReconciler(
if err != nil {
return fmt.Errorf("stream: %w", err)
}
- subject := "$KV." + kv.Bucket() + "." + KeyFromObject(forObj)
+ subject := "$KV." + kv.Bucket() + "." + hz.KeyFromObject(forObj)
con, err := stream.CreateOrUpdateConsumer(ctx, jetstream.ConsumerConfig{
Name: "rc_" + forObj.ObjectKind(),
Durable: "rc_" + forObj.ObjectKind(),
@@ -405,7 +448,7 @@ func (c *Controller) startReconciler(
c.consumeContexts = append(c.consumeContexts, cc)
for _, obj := range opt.reconOwns {
- subject := "$KV." + kv.Bucket() + "." + KeyFromObject(obj)
+ subject := "$KV." + kv.Bucket() + "." + hz.KeyFromObject(obj)
con, err := stream.CreateOrUpdateConsumer(ctx, jetstream.ConsumerConfig{
Name: "rc_" + forObj.ObjectKind() + "_o_" + obj.ObjectKind(),
Description: "Reconciler for " + forObj.ObjectKind() + " owns " + obj.ObjectKind(),
@@ -433,7 +476,7 @@ func (c *Controller) startReconciler(
// This consumer is for the child objects of the parent object.
// Hence, check if the child object (msg) is owned by the parent
// for which the reconciler is running.
- var emptyObject MetaOnlyObject
+ var emptyObject hz.MetaOnlyObject
if err := json.Unmarshal(msg.Data(), &emptyObject); err != nil {
slog.Error("unmarshal msg to empty object", "error", err)
_ = msg.Term()
@@ -449,7 +492,7 @@ func (c *Controller) startReconciler(
return
}
// Key for the owner (parent) object.
- key := KeyFromObject(ObjectKey{
+ key := hz.KeyFromObject(hz.ObjectKey{
Group: ownerRef.Group,
Kind: ownerRef.Kind,
Name: ownerRef.Name,
@@ -538,7 +581,7 @@ func (c *Controller) stopWaitTimeout() bool {
// owner object of the child (parent).
func (c *Controller) handleControlLoop(
ctx context.Context,
- reconciler Reconciler,
+ reconciler hz.Reconciler,
kv jetstream.KeyValue,
mutex mutex,
key string,
@@ -568,7 +611,7 @@ func (c *Controller) handleControlLoop(
return
}
// Get the object key from the nats subject / kv key.
- objKey, err := ObjectKeyFromString(key)
+ objKey, err := hz.ObjectKeyFromString(key)
if err != nil {
slog.Error("getting object key from key", "key", key, "error", err)
_ = msg.NakWithDelay(time.Second)
@@ -598,11 +641,11 @@ func (c *Controller) handleControlLoop(
}()
// Prepare the request and call the reconciler.
- req := Request{
+ req := hz.Request{
Key: objKey,
}
var (
- reconcileResult Result
+ reconcileResult hz.Result
reconcileErr error
reconcileDone = make(chan struct{})
)
@@ -767,10 +810,3 @@ func opFromMsg(msg jetstream.Msg) jetstream.KeyValueOp {
}
return kvop
}
-
-func IgnoreNotFound(err error) error {
- if errors.Is(err, ErrNotFound) {
- return nil
- }
- return err
-}
diff --git a/pkg/hz/controller_test.go b/pkg/controller/controller_test.go
similarity index 83%
rename from pkg/hz/controller_test.go
rename to pkg/controller/controller_test.go
index 5afb698..d9bf641 100644
--- a/pkg/hz/controller_test.go
+++ b/pkg/controller/controller_test.go
@@ -1,4 +1,4 @@
-package hz_test
+package controller_test
import (
"context"
@@ -7,12 +7,52 @@ import (
"testing"
"time"
+ "github.com/verifa/horizon/pkg/controller"
"github.com/verifa/horizon/pkg/hz"
"github.com/verifa/horizon/pkg/server"
"github.com/verifa/horizon/pkg/store"
tu "github.com/verifa/horizon/pkg/testutil"
)
+var _ (hz.Objecter) = (*DummyObject)(nil)
+
+type DummyObject struct {
+ hz.ObjectMeta `json:"metadata,omitempty" cue:""`
+
+ Spec struct{} `json:"spec,omitempty" cue:""`
+ Status struct{} `json:"status,omitempty"`
+}
+
+func (o DummyObject) ObjectVersion() string {
+ return "v1"
+}
+
+func (o DummyObject) ObjectGroup() string {
+ return "DummyGroup"
+}
+
+func (o DummyObject) ObjectKind() string {
+ return "DummyObject"
+}
+
+type ChildObject struct {
+ hz.ObjectMeta `json:"metadata,omitempty"`
+
+ Spec struct{} `json:"spec,omitempty" cue:",opt"`
+}
+
+func (o ChildObject) ObjectGroup() string {
+ return "ChildGroup"
+}
+
+func (o ChildObject) ObjectVersion() string {
+ return "v1"
+}
+
+func (o ChildObject) ObjectKind() string {
+ return "ChildObject"
+}
+
type DummyReconciler struct {
DummyClient hz.ObjectClient[DummyObject]
}
@@ -45,22 +85,22 @@ func TestReconciler(t *testing.T) {
dr := DummyReconciler{
DummyClient: dummyClient,
}
- ctlr, err := hz.StartController(
+ ctlr, err := controller.Start(
ctx,
ti.Conn,
- hz.WithControllerReconciler(&dr),
- hz.WithControllerFor(&DummyObject{}),
- hz.WithControllerOwns(&ChildObject{}),
+ controller.WithReconciler(&dr),
+ controller.WithFor(&DummyObject{}),
+ controller.WithOwns(&ChildObject{}),
)
tu.AssertNoError(t, err)
defer ctlr.Stop()
// Start controller for child object.
- childCtlr, err := hz.StartController(
+ childCtlr, err := controller.Start(
ctx,
ti.Conn,
- hz.WithControllerReconciler(&ChildReconciler{}),
- hz.WithControllerFor(&ChildObject{}),
+ controller.WithReconciler(&ChildReconciler{}),
+ controller.WithFor(&ChildObject{}),
)
tu.AssertNoError(t, err)
defer childCtlr.Stop()
@@ -84,8 +124,8 @@ func TestReconciler(t *testing.T) {
Group: do.ObjectGroup(),
Version: do.ObjectVersion(),
Kind: do.ObjectKind(),
- Namespace: do.Namespace,
- Name: do.Name,
+ Namespace: do.ObjectNamespace(),
+ Name: do.ObjectName(),
},
},
},
@@ -121,11 +161,11 @@ func TestReconcilerPanic(t *testing.T) {
dummyClient := hz.ObjectClient[DummyObject]{Client: client}
pr := PanicReconciler{}
pr.wg.Add(2)
- ctlr, err := hz.StartController(
+ ctlr, err := controller.Start(
ctx,
ti.Conn,
- hz.WithControllerReconciler(&pr),
- hz.WithControllerFor(&DummyObject{}),
+ controller.WithReconciler(&pr),
+ controller.WithFor(&DummyObject{}),
)
tu.AssertNoError(t, err)
defer ctlr.Stop()
@@ -196,11 +236,11 @@ func TestReconcilerSlow(t *testing.T) {
sr := SlowReconciler{}
sr.wg.Add(2)
- ctlr, err := hz.StartController(
+ ctlr, err := controller.Start(
ctx,
ti.Conn,
- hz.WithControllerReconciler(&sr),
- hz.WithControllerFor(&DummyObject{}),
+ controller.WithReconciler(&sr),
+ controller.WithFor(&DummyObject{}),
)
tu.AssertNoError(t, err)
t.Cleanup(func() {
@@ -269,11 +309,11 @@ func TestReconcilerWaitForFinish(t *testing.T) {
sr := SleepReconciler{
dur: time.Second * 3,
}
- ctlr, err := hz.StartController(
+ ctlr, err := controller.Start(
ctx,
ti.Conn,
- hz.WithControllerReconciler(&sr),
- hz.WithControllerFor(&DummyObject{}),
+ controller.WithReconciler(&sr),
+ controller.WithFor(&DummyObject{}),
)
tu.AssertNoError(t, err)
@@ -340,21 +380,21 @@ func TestReconcilerConcurrent(t *testing.T) {
}
// Start a few instances of the controller.
for i := 0; i < 5; i++ {
- ctlr, err := hz.StartController(
+ ctlr, err := controller.Start(
ctx,
ti.Conn,
- hz.WithControllerReconciler(&cr),
- hz.WithControllerFor(&DummyObject{}),
+ controller.WithReconciler(&cr),
+ controller.WithFor(&DummyObject{}),
)
tu.AssertNoError(t, err)
defer ctlr.Stop()
}
// Start controller for child object
- childCtlr, err := hz.StartController(
+ childCtlr, err := controller.Start(
ctx,
ti.Conn,
- hz.WithControllerReconciler(&ChildReconciler{}),
- hz.WithControllerFor(&ChildObject{}),
+ controller.WithReconciler(&ChildReconciler{}),
+ controller.WithFor(&ChildObject{}),
)
tu.AssertNoError(t, err)
defer childCtlr.Stop()
diff --git a/pkg/hz/mutex.go b/pkg/controller/mutex.go
similarity index 88%
rename from pkg/hz/mutex.go
rename to pkg/controller/mutex.go
index 506ef2f..918b24f 100644
--- a/pkg/hz/mutex.go
+++ b/pkg/controller/mutex.go
@@ -1,4 +1,4 @@
-package hz
+package controller
import (
"context"
@@ -126,3 +126,14 @@ func (l *lock) Release() error {
l.released = true
return nil
}
+
+// isErrWrongLastSequence returns true if the error is caused by a write
+// operation to a stream with the wrong last sequence.
+// For example, if a kv update with an outdated revision.
+func isErrWrongLastSequence(err error) bool {
+ var apiErr *jetstream.APIError
+ if errors.As(err, &apiErr) {
+ return apiErr.ErrorCode == jetstream.JSErrCodeStreamWrongLastSequence
+ }
+ return false
+}
diff --git a/pkg/hz/mutex_test.go b/pkg/controller/mutex_test.go
similarity index 77%
rename from pkg/hz/mutex_test.go
rename to pkg/controller/mutex_test.go
index 154ed6b..37b1f87 100644
--- a/pkg/hz/mutex_test.go
+++ b/pkg/controller/mutex_test.go
@@ -1,10 +1,11 @@
-package hz_test
+package controller_test
import (
"context"
"testing"
"github.com/nats-io/nats.go/jetstream"
+ "github.com/verifa/horizon/pkg/controller"
"github.com/verifa/horizon/pkg/hz"
"github.com/verifa/horizon/pkg/server"
tu "github.com/verifa/horizon/pkg/testutil"
@@ -16,14 +17,14 @@ func TestMutex(t *testing.T) {
js, err := jetstream.New(ti.Conn)
tu.AssertNoError(t, err)
- mutex, err := hz.MutexFromBucket(ctx, js, hz.BucketObjects)
+ mutex, err := controller.MutexFromBucket(ctx, js, hz.BucketObjects)
tu.AssertNoError(t, err)
lock, err := mutex.Lock(ctx, "test")
tu.AssertNoError(t, err)
_, err = mutex.Lock(ctx, "test")
- tu.AssertErrorIs(t, err, hz.ErrKeyLocked)
+ tu.AssertErrorIs(t, err, controller.ErrKeyLocked)
err = lock.Release()
tu.AssertNoError(t, err)
diff --git a/pkg/controller/openapi.go b/pkg/controller/openapi.go
new file mode 100644
index 0000000..6f81142
--- /dev/null
+++ b/pkg/controller/openapi.go
@@ -0,0 +1,54 @@
+package controller
+
+import (
+ "encoding/json"
+ "fmt"
+
+ "cuelang.org/go/cue"
+ "cuelang.org/go/cue/cuecontext"
+ "cuelang.org/go/encoding/openapi"
+ "github.com/verifa/horizon/pkg/hz"
+ "github.com/verifa/horizon/pkg/internal/hzcue"
+ "github.com/verifa/horizon/pkg/internal/openapiv3"
+)
+
+func OpenAPIV3SchemaFromObject(obj hz.Objecter) (*openapiv3.Schema, error) {
+ bOpenAPI, err := openAPIV3FromObject(obj)
+ if err != nil {
+ return nil, err
+ }
+ spec := openapiv3.Spec{}
+ if err := json.Unmarshal(bOpenAPI, &spec); err != nil {
+ return nil, fmt.Errorf("unmarshalling open api spec: %w", err)
+ }
+ if len(spec.Components.Schemas) != 1 {
+ return nil, fmt.Errorf(
+ "expected 1 schema, got %d",
+ len(spec.Components.Schemas),
+ )
+ }
+ for key, schema := range spec.Components.Schemas {
+ schema.Key = key
+ return &schema, nil
+ }
+ return nil, fmt.Errorf("no schema")
+}
+
+func openAPIV3FromObject(obj hz.Objecter) ([]byte, error) {
+ cCtx := cuecontext.New()
+ cueSpec, err := hzcue.SpecFromObject(cCtx, obj)
+ if err != nil {
+ return nil, err
+ }
+
+ // We need to wrap the cue spec into a schema definition.
+ defPath := cue.MakePath(cue.Def(obj.ObjectKind()))
+ oapiSpec := cCtx.CompileString("{}").FillPath(defPath, cueSpec)
+ bOpenAPI, err := openapi.Gen(oapiSpec, &openapi.Config{
+ ExpandReferences: true,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("generating open api spec: %w", err)
+ }
+ return bOpenAPI, nil
+}
diff --git a/pkg/extensions/core/crd.go b/pkg/extensions/core/crd.go
new file mode 100644
index 0000000..7a3b154
--- /dev/null
+++ b/pkg/extensions/core/crd.go
@@ -0,0 +1,127 @@
+package core
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+
+ "github.com/verifa/horizon/pkg/hz"
+ "github.com/verifa/horizon/pkg/internal/openapiv3"
+)
+
+const (
+ ObjectKindCustomResourceDefinition = "CustomResourceDefinition"
+)
+
+var (
+ _ hz.Objecter = (*CustomResourceDefinition)(nil)
+ _ hz.ObjectOpenAPIV3Schemer = (*CustomResourceDefinition)(nil)
+)
+
+type CustomResourceDefinition struct {
+ hz.ObjectMeta `json:"metadata,omitempty" cue:""`
+
+ Spec *CustomResourceDefinitionSpec `json:"spec,omitempty"`
+ Status *CustomResourceDefinitionStatus `json:"status,omitempty"`
+}
+
+func (a CustomResourceDefinition) ObjectGroup() string {
+ return ObjectGroup
+}
+
+func (a CustomResourceDefinition) ObjectVersion() string {
+ return ObjectVersion
+}
+
+func (a CustomResourceDefinition) ObjectKind() string {
+ return "CustomResourceDefinition"
+}
+
+func (a CustomResourceDefinition) OpenAPIV3Schema() (*openapiv3.Schema, error) {
+ return &openapiv3.Schema{}, nil
+}
+
+type CustomResourceDefinitionSpec struct {
+ Group *string `json:"group,omitempty" cue:""`
+ Version *string `json:"version,omitempty" cue:""`
+ Names *CustomResourceDefinitionNames `json:"names,omitempty" cue:""`
+ Schema *CustomResourceDefinitionSchema `json:"schema,omitempty" cue:""`
+}
+
+type CustomResourceDefinitionSchema struct {
+ OpenAPIV3Schema *openapiv3.Schema `json:"openAPIV3Schema,omitempty" cue:""`
+}
+
+type CustomResourceDefinitionNames struct {
+ // Kind is the singular type in PascalCase.
+ // Your resource manifests use this.
+ Kind *string `json:"kind,omitempty" cue:""`
+ // Singular is a lower case alias for the Kind.
+ Singular *string `json:"singular,omitempty" cue:""`
+}
+
+type CustomResourceDefinitionStatus struct{}
+
+var _ hz.Validator = (*CustomResourceDefinitionValidate)(nil)
+
+type CustomResourceDefinitionValidate struct{}
+
+func (c *CustomResourceDefinitionValidate) ValidateCreate(
+ ctx context.Context,
+ data []byte,
+) error {
+ return c.validateName(data)
+}
+
+func (c *CustomResourceDefinitionValidate) ValidateUpdate(
+ ctx context.Context,
+ old []byte,
+ data []byte,
+) error {
+ return c.validateName(data)
+}
+
+func (c *CustomResourceDefinitionValidate) validateName(
+ data []byte,
+) error {
+ var crd CustomResourceDefinition
+ if err := json.Unmarshal(data, &crd); err != nil {
+ return fmt.Errorf(
+ "unmarshalling custom resource definition: %w",
+ err,
+ )
+ }
+ if crd.Spec.Group == nil {
+ return fmt.Errorf("missing group")
+ }
+ if crd.Spec.Names == nil {
+ return fmt.Errorf("missing names")
+ }
+ if crd.Spec.Names.Kind == nil {
+ return fmt.Errorf("missing kind")
+ }
+ if crd.Spec.Names.Singular == nil {
+ return fmt.Errorf("missing singular")
+ }
+ if crd.Spec.Schema == nil {
+ return fmt.Errorf("missing schema")
+ }
+ if crd.Spec.Schema.OpenAPIV3Schema == nil {
+ return fmt.Errorf("missing openAPIV3Schema")
+ }
+
+ expectedName := fmt.Sprintf(
+ "%s-%s-%s",
+ *crd.Spec.Group,
+ *crd.Spec.Version,
+ *crd.Spec.Names.Kind,
+ )
+ if crd.ObjectMeta.Name != expectedName {
+ return fmt.Errorf(
+ "invalid name: %q, expected: %q",
+ crd.ObjectMeta.Name,
+ expectedName,
+ )
+ }
+ return nil
+}
diff --git a/pkg/extensions/core/secret_test.go b/pkg/extensions/core/secret_test.go
index 0a3dc24..5cf5e6a 100644
--- a/pkg/extensions/core/secret_test.go
+++ b/pkg/extensions/core/secret_test.go
@@ -5,6 +5,7 @@ import (
"encoding/json"
"testing"
+ "github.com/verifa/horizon/pkg/controller"
"github.com/verifa/horizon/pkg/extensions/core"
"github.com/verifa/horizon/pkg/hz"
"github.com/verifa/horizon/pkg/server"
@@ -15,10 +16,10 @@ func TestSecrets(t *testing.T) {
ctx := context.Background()
ts := server.Test(t, ctx)
- ctlr, err := hz.StartController(
+ ctlr, err := controller.Start(
ctx,
ts.Conn,
- hz.WithControllerFor(core.Secret{}),
+ controller.WithFor(core.Secret{}),
)
tu.AssertNoError(t, err)
t.Cleanup(func() {
diff --git a/pkg/hz/client.go b/pkg/hz/client.go
index 8b060aa..dcd4233 100644
--- a/pkg/hz/client.go
+++ b/pkg/hz/client.go
@@ -12,7 +12,6 @@ import (
"time"
"github.com/nats-io/nats.go"
- "github.com/nats-io/nats.go/jetstream"
"github.com/tidwall/sjson"
)
@@ -323,37 +322,6 @@ func (c Client) SubjectPrefix() string {
return "HZ.api."
}
-func (c Client) Schema(
- ctx context.Context,
- key ObjectKeyer,
-) (Schema, error) {
- subject := c.SubjectPrefix() + fmt.Sprintf(
- SubjectStoreSchema,
- key.ObjectGroup(),
- key.ObjectVersion(),
- key.ObjectKind(),
- )
- ctx, cancel := context.WithTimeout(ctx, time.Second)
- defer cancel()
- reply, err := c.Conn.RequestWithContext(ctx, subject, nil)
- if err != nil {
- if errors.Is(err, nats.ErrNoResponders) {
- return Schema{}, errors.New("controller not responding")
- }
- return Schema{}, fmt.Errorf("request: %w", err)
- }
-
- var schema Schema
- if err := json.Unmarshal(reply.Data, &schema); err != nil {
- return Schema{}, fmt.Errorf(
- "unmarshal reply error: %w",
- err,
- )
- }
-
- return schema, nil
-}
-
type ValidateOption func(*validateOptions)
func WithValidateObject(obj Objecter) ValidateOption {
@@ -938,17 +906,6 @@ func (c *Client) Run(
return reply.Data, nil
}
-// isErrWrongLastSequence returns true if the error is caused by a write
-// operation to a stream with the wrong last sequence.
-// For example, if a kv update with an outdated revision.
-func isErrWrongLastSequence(err error) bool {
- var apiErr *jetstream.APIError
- if errors.As(err, &apiErr) {
- return apiErr.ErrorCode == jetstream.JSErrCodeStreamWrongLastSequence
- }
- return false
-}
-
type RunMsg struct {
Timeout time.Duration `json:"timeout,omitempty"`
Data json.RawMessage `json:"data,omitempty"`
diff --git a/pkg/hz/error.go b/pkg/hz/error.go
index 7aeac6c..25b46a2 100644
--- a/pkg/hz/error.go
+++ b/pkg/hz/error.go
@@ -161,3 +161,10 @@ func RespondStatus(
response.Header.Add(HeaderStatus, fmt.Sprintf("%d", status))
return msg.RespondMsg(response)
}
+
+func IgnoreNotFound(err error) error {
+ if errors.Is(err, ErrNotFound) {
+ return nil
+ }
+ return err
+}
diff --git a/pkg/hz/object.go b/pkg/hz/object.go
index 1e93757..0524067 100644
--- a/pkg/hz/object.go
+++ b/pkg/hz/object.go
@@ -7,9 +7,15 @@ import (
"strings"
"time"
+ "cuelang.org/go/cue"
+ "cuelang.org/go/cue/ast"
+ "github.com/verifa/horizon/pkg/internal/hzcue"
"github.com/verifa/horizon/pkg/internal/managedfields"
+ "github.com/verifa/horizon/pkg/internal/openapiv3"
)
+const NamespaceRoot = "root"
+
// Objecter is an interface that represents an object in the Horizon API.
type Objecter interface {
ObjectKeyer
@@ -29,6 +35,10 @@ type ObjectKeyer interface {
ObjectName() string
}
+type ObjectOpenAPIV3Schemer interface {
+ OpenAPIV3Schema() (*openapiv3.Schema, error)
+}
+
func validateKeyStrict(key ObjectKeyer) error {
var errs error
isEmptyOrStar := func(s string) bool {
@@ -336,6 +346,8 @@ func (o OwnerReference) IsOwnedBy(owner Objecter) bool {
o.Namespace == owner.ObjectNamespace()
}
+var _ hzcue.CueExpressioner = (*Time)(nil)
+
type Time struct {
time.Time
}
@@ -347,6 +359,10 @@ func (t *Time) IsPast() bool {
return t.Before(time.Now())
}
+func (t Time) CueExpr(cCtx *cue.Context) (cue.Value, error) {
+ return cCtx.BuildExpr(ast.NewIdent("string")), nil
+}
+
// Finalizers are a way to prevent garbage collection of objects until a
// controller has finished some cleanup logic.
type Finalizers []string
diff --git a/pkg/hz/object_test.go b/pkg/hz/object_test.go
index b717e55..c3e206d 100644
--- a/pkg/hz/object_test.go
+++ b/pkg/hz/object_test.go
@@ -29,24 +29,6 @@ func (o DummyObject) ObjectKind() string {
return "DummyObject"
}
-type ChildObject struct {
- hz.ObjectMeta `json:"metadata,omitempty"`
-
- Spec struct{} `json:"spec,omitempty" cue:",opt"`
-}
-
-func (o ChildObject) ObjectGroup() string {
- return "ChildGroup"
-}
-
-func (o ChildObject) ObjectVersion() string {
- return "v1"
-}
-
-func (o ChildObject) ObjectKind() string {
- return "ChildObject"
-}
-
func TestGenericObjectMarshal(t *testing.T) {
expObj := map[string]interface{}{
"apiVersion": "core/v1",
diff --git a/pkg/hz/reconciler.go b/pkg/hz/reconciler.go
index 2d96dcf..3b5d395 100644
--- a/pkg/hz/reconciler.go
+++ b/pkg/hz/reconciler.go
@@ -5,8 +5,6 @@ import (
"time"
)
-const NamespaceRoot = "root"
-
type Reconciler interface {
Reconcile(context.Context, Request) (Result, error)
}
diff --git a/pkg/hz/validator.go b/pkg/hz/validator.go
index e882912..3c723c9 100644
--- a/pkg/hz/validator.go
+++ b/pkg/hz/validator.go
@@ -2,29 +2,37 @@ package hz
import (
"context"
+ "encoding/json"
"errors"
"fmt"
"cuelang.org/go/cue"
"cuelang.org/go/cue/cuecontext"
cueerrors "cuelang.org/go/cue/errors"
+ "github.com/verifa/horizon/pkg/internal/hzcue"
)
type Validator interface {
ValidateCreate(ctx context.Context, data []byte) error
ValidateUpdate(ctx context.Context, old, data []byte) error
- ValidateDelete(ctx context.Context, data []byte) error
}
-var _ Validator = (*ZeroValidator)(nil)
+var _ Validator = (*ValidateNothing)(nil)
-type ZeroValidator struct{}
+// ValidateNothing is a validator that does not validate anything but it
+// implements [Validator].
+// It is therefore useful to embed in other validators that only need to
+// implement a subset of the [Validator] interface orr for testing.
+type ValidateNothing struct{}
-func (z *ZeroValidator) ValidateCreate(ctx context.Context, data []byte) error {
+func (z *ValidateNothing) ValidateCreate(
+ ctx context.Context,
+ data []byte,
+) error {
return nil
}
-func (z *ZeroValidator) ValidateUpdate(
+func (z *ValidateNothing) ValidateUpdate(
ctx context.Context,
old []byte,
data []byte,
@@ -32,23 +40,20 @@ func (z *ZeroValidator) ValidateUpdate(
return nil
}
-func (z *ZeroValidator) ValidateDelete(ctx context.Context, data []byte) error {
- return nil
-}
-
-var _ Validator = (*CUEValidator)(nil)
+var _ Validator = (*ValidateCUE)(nil)
-type CUEValidator struct {
+// ValidateCUE is a validator that uses CUE to validate objects.
+type ValidateCUE struct {
Object Objecter
cCtx *cue.Context
cueDef cue.Value
}
-func (v *CUEValidator) ValidateCreate(ctx context.Context, data []byte) error {
+func (v *ValidateCUE) ValidateCreate(ctx context.Context, data []byte) error {
return v.validate(ctx, data)
}
-func (v *CUEValidator) ValidateUpdate(
+func (v *ValidateCUE) ValidateUpdate(
ctx context.Context,
old []byte,
data []byte,
@@ -56,16 +61,12 @@ func (v *CUEValidator) ValidateUpdate(
return v.validate(ctx, data)
}
-func (v *CUEValidator) ValidateDelete(ctx context.Context, data []byte) error {
- return nil
-}
-
-func (v *CUEValidator) ParseObject() error {
+func (v *ValidateCUE) ParseObject() error {
if v.cCtx != nil {
return errors.New("cue context already initialised")
}
cCtx := cuecontext.New()
- cueSpec, err := cueSpecFromObject(cCtx, v.Object)
+ cueSpec, err := hzcue.SpecFromObject(cCtx, v.Object)
if err != nil {
return fmt.Errorf("generating cue spec: %w", err)
}
@@ -74,7 +75,7 @@ func (v *CUEValidator) ParseObject() error {
return nil
}
-func (v *CUEValidator) validate(_ context.Context, data []byte) error {
+func (v *ValidateCUE) validate(_ context.Context, data []byte) error {
if v.cCtx == nil {
err := v.ParseObject()
if err != nil {
@@ -116,3 +117,40 @@ func (v *CUEValidator) validate(_ context.Context, data []byte) error {
}
return nil
}
+
+var _ Validator = (*ValidateNamespaceRoot)(nil)
+
+// ValidateNamespaceRoot is a validator that checks if the namespace is root.
+type ValidateNamespaceRoot struct{}
+
+func (v *ValidateNamespaceRoot) ValidateCreate(
+ ctx context.Context,
+ data []byte,
+) error {
+ return v.validateRootNamespace(data)
+}
+
+func (v *ValidateNamespaceRoot) ValidateUpdate(
+ ctx context.Context,
+ old []byte,
+ data []byte,
+) error {
+ return v.validateRootNamespace(data)
+}
+
+func (v *ValidateNamespaceRoot) validateRootNamespace(
+ data []byte,
+) error {
+ meta := MetaOnlyObject{}
+ if err := json.Unmarshal(data, &meta); err != nil {
+ return fmt.Errorf("unmarshal data metadata: %w", err)
+ }
+ if meta.Namespace != NamespaceRoot {
+ return fmt.Errorf(
+ "namespace must be %q but is %q",
+ NamespaceRoot,
+ meta.Namespace,
+ )
+ }
+ return nil
+}
diff --git a/pkg/hz/watcher.go b/pkg/hz/watcher.go
index d017553..62389cb 100644
--- a/pkg/hz/watcher.go
+++ b/pkg/hz/watcher.go
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"log/slog"
+ "strings"
"time"
"github.com/nats-io/nats.go"
@@ -326,3 +327,34 @@ const (
// store.
EventOperationPurge EventOperation = "purge"
)
+
+// keyFromMsgSubject takes the subject for a msg and converts it to the
+// corresponding key for a kv store.
+//
+// Under the hood, a nats kv store creates a stream.
+// The subjects for messages on that stream contain a prefix.
+// If we remove the prefix, we get the key which can be used to access values
+// (messages) from the kv store.
+func keyFromMsgSubject(kv jetstream.KeyValue, msg jetstream.Msg) string {
+ key := strings.TrimPrefix(
+ msg.Subject(),
+ fmt.Sprintf("$KV.%s.", kv.Bucket()),
+ )
+ return key
+}
+
+func opFromMsg(msg jetstream.Msg) jetstream.KeyValueOp {
+ kvop := jetstream.KeyValuePut
+ if len(msg.Headers()) > 0 {
+ op := msg.Headers().Get("KV-Operation")
+ switch op {
+ case "DEL":
+ kvop = jetstream.KeyValueDelete
+ case "PURGE":
+ kvop = jetstream.KeyValuePurge
+ default:
+ kvop = jetstream.KeyValuePut
+ }
+ }
+ return kvop
+}
diff --git a/pkg/hz/cue.go b/pkg/internal/hzcue/cue.go
similarity index 89%
rename from pkg/hz/cue.go
rename to pkg/internal/hzcue/cue.go
index 4e2e496..7d9ddfd 100644
--- a/pkg/hz/cue.go
+++ b/pkg/internal/hzcue/cue.go
@@ -1,4 +1,4 @@
-package hz
+package hzcue
import (
"bytes"
@@ -16,7 +16,17 @@ import (
"github.com/verifa/horizon/pkg/internal/managedfields"
)
-func cueSpecFromObject(cCtx *cue.Context, obj Objecter) (cue.Value, error) {
+type Objecter interface {
+ ObjectGroup() string
+ ObjectVersion() string
+ ObjectKind() string
+}
+
+type CueExpressioner interface {
+ CueExpr(*cue.Context) (cue.Value, error)
+}
+
+func SpecFromObject(cCtx *cue.Context, obj Objecter) (cue.Value, error) {
cueVal := cCtx.CompileString("{}")
kindPath := cue.ParsePath("kind")
@@ -40,7 +50,7 @@ func cueSpecFromObject(cCtx *cue.Context, obj Objecter) (cue.Value, error) {
)
}
t := reflect.TypeOf(obj)
- cueObj, err := cueEncodeStruct(cCtx, t)
+ cueObj, err := encodeStruct(cCtx, t)
if err != nil {
return cue.Value{}, fmt.Errorf("encoding struct: %w", err)
}
@@ -65,9 +75,9 @@ func cueSpecFromObject(cCtx *cue.Context, obj Objecter) (cue.Value, error) {
return cueDef, nil
}
-func cueEncodeStruct(cCtx *cue.Context, t reflect.Type) (cue.Value, error) {
+func encodeStruct(cCtx *cue.Context, t reflect.Type) (cue.Value, error) {
if t.Kind() == reflect.Ptr {
- return cueEncodeStruct(cCtx, t.Elem())
+ return encodeStruct(cCtx, t.Elem())
}
if t.Kind() != reflect.Struct {
return cue.Value{}, errors.New("value must be a struct")
@@ -76,7 +86,7 @@ func cueEncodeStruct(cCtx *cue.Context, t reflect.Type) (cue.Value, error) {
// Handle special case of struct with one field which is embedded, like
// [Time] in the ObjectMeta struct.
if t.NumField() == 1 && t.Field(0).Anonymous {
- return cueEncodeStruct(cCtx, t.Field(0).Type)
+ return encodeStruct(cCtx, t.Field(0).Type)
}
val := cCtx.CompileString("{}")
@@ -91,7 +101,7 @@ func cueEncodeStruct(cCtx *cue.Context, t reflect.Type) (cue.Value, error) {
if fieldType.Kind() == reflect.Pointer {
fieldType = fieldType.Elem()
}
- fieldPath, ok := cueFieldPath(field)
+ fieldPath, ok := fieldPath(field)
if !ok {
continue
}
@@ -102,7 +112,7 @@ func cueEncodeStruct(cCtx *cue.Context, t reflect.Type) (cue.Value, error) {
jTag, ok := field.Tag.Lookup("json")
// If no json tag, or a json tag with an empty name.
if !ok || strings.Split(jTag, ",")[0] == "" {
- embedVal, err := cueEncodeStruct(cCtx, fieldType)
+ embedVal, err := encodeStruct(cCtx, fieldType)
if err != nil {
return cue.Value{}, fmt.Errorf(
"encoding embedded struct %q: %w",
@@ -115,7 +125,7 @@ func cueEncodeStruct(cCtx *cue.Context, t reflect.Type) (cue.Value, error) {
}
}
- fieldExpr, err := cueEncodeField(cCtx, fieldType)
+ fieldExpr, err := encodeField(cCtx, fieldType)
if err != nil {
return cue.Value{}, fmt.Errorf(
"encoding field %q: %w",
@@ -145,18 +155,20 @@ func cueEncodeStruct(cCtx *cue.Context, t reflect.Type) (cue.Value, error) {
return val, nil
}
-func cueEncodeField(
+func encodeField(
cCtx *cue.Context,
fieldType reflect.Type,
) (cue.Value, error) {
if fieldType.Kind() == reflect.Ptr {
- return cueEncodeField(cCtx, fieldType.Elem())
+ return encodeField(cCtx, fieldType.Elem())
}
// Handle special types.
iVal := reflect.New(fieldType).Elem().Interface()
- switch iVal.(type) {
- case Time, time.Time:
+ switch val := iVal.(type) {
+ case CueExpressioner:
+ return val.CueExpr(cCtx)
+ case time.Time:
// This was the best attempt at getting formatting for time, but it
// involves importing stuff and complicated things a lot right now.
// importTime := ast.NewImport(nil, "time")
@@ -179,7 +191,7 @@ func cueEncodeField(
switch fieldType.Kind() {
case reflect.Struct:
- return cueEncodeStruct(cCtx, fieldType)
+ return encodeStruct(cCtx, fieldType)
// Had an error treating arrays differently when generating the OpenAPI
// spec, from cue. So just treat them like slices... sigh.
@@ -200,7 +212,7 @@ func cueEncodeField(
// return cCtx.NewList(vals...), nil
case reflect.Slice, reflect.Array:
elem := fieldType.Elem()
- elemVal, err := cueEncodeField(cCtx, elem)
+ elemVal, err := encodeField(cCtx, elem)
if err != nil {
return cue.Value{}, err
}
@@ -277,7 +289,7 @@ func cueEncodeField(
}
}
-func cueFieldPath(field reflect.StructField) (cue.Path, bool) {
+func fieldPath(field reflect.StructField) (cue.Path, bool) {
fieldName := field.Name
isRequired := false
jTag, ok := field.Tag.Lookup("json")
diff --git a/pkg/hz/cue_test.go b/pkg/internal/hzcue/cue_test.go
similarity index 92%
rename from pkg/hz/cue_test.go
rename to pkg/internal/hzcue/cue_test.go
index 236fb98..fad4303 100644
--- a/pkg/hz/cue_test.go
+++ b/pkg/internal/hzcue/cue_test.go
@@ -1,4 +1,4 @@
-package hz
+package hzcue_test
import (
"encoding/json"
@@ -9,13 +9,15 @@ import (
"cuelang.org/go/cue/cuecontext"
"cuelang.org/go/cue/format"
"cuelang.org/go/encoding/openapi"
+ "github.com/verifa/horizon/pkg/hz"
+ "github.com/verifa/horizon/pkg/internal/hzcue"
tu "github.com/verifa/horizon/pkg/testutil"
)
type cueObj struct {
- ObjectMeta `json:"metadata,omitempty" cue:""`
- Spec cueSpec `json:"spec"`
- Status cueStatus `json:"status"`
+ hz.ObjectMeta `json:"metadata,omitempty" cue:""`
+ Spec cueSpec `json:"spec"`
+ Status cueStatus `json:"status"`
}
func (s cueObj) ObjectKind() string {
@@ -138,7 +140,7 @@ func TestCueDefinition(t *testing.T) {
// testRaw := cueValToBytes(t, testType)
// fmt.Println(string(testRaw))
- cueDef, err := cueSpecFromObject(cCtx, cueObj{})
+ cueDef, err := hzcue.SpecFromObject(cCtx, cueObj{})
tu.AssertNoError(t, err)
tu.AssertNoError(t, cueDef.Err())
tu.AssertNoError(t, cueDef.Validate(cue.All()))
diff --git a/pkg/internal/managedfields/managedfields.go b/pkg/internal/managedfields/managedfields.go
index 13998d0..6221efa 100644
--- a/pkg/internal/managedfields/managedfields.go
+++ b/pkg/internal/managedfields/managedfields.go
@@ -77,20 +77,17 @@ func managedFieldsV1Array(
switch elem := elem.(type) {
case map[string]interface{}:
// HACK: for now we hardcode that an object within an array must
- // have an
- // id field. This is the merge key.
+ // have an id field. This is the merge key.
idv, ok := elem["id"]
if !ok {
// If the merge key does not exist, we must say that this
- // manager
- // owns this field. This is bad and wrong.
+ // manager owns this field. This is bad and wrong.
return defaultFields
}
idStr, ok := idv.(string)
if !ok {
// If the merge key is not a string, we must say that this
- // manager
- // owns this field. This is bad and wrong.
+ // manager owns this field. This is bad and wrong.
return defaultFields
}
key := FieldsV1Key{
diff --git a/pkg/internal/managedfields/managedfields_test.go b/pkg/internal/managedfields/managedfields_test.go
index 6f1958a..638d386 100644
--- a/pkg/internal/managedfields/managedfields_test.go
+++ b/pkg/internal/managedfields/managedfields_test.go
@@ -72,6 +72,20 @@ func TestManagedFieldsV1(t *testing.T) {
},
},
},
+ {
+ name: "array without id",
+ json: `
+ {
+ "slice": [
+ {"field": "value"}
+ ]
+ }`,
+ exp: FieldsV1{
+ Fields: map[FieldsV1Key]FieldsV1{
+ fkey("slice"): {},
+ },
+ },
+ },
{
name: "empty-metadata",
json: `{
diff --git a/pkg/hz/openapi.go b/pkg/internal/openapiv3/openapi.go
similarity index 75%
rename from pkg/hz/openapi.go
rename to pkg/internal/openapiv3/openapi.go
index 0059931..d4a36d3 100644
--- a/pkg/hz/openapi.go
+++ b/pkg/internal/openapiv3/openapi.go
@@ -1,66 +1,17 @@
-package hz
+package openapiv3
import (
"bytes"
"encoding/json"
"fmt"
-
- "cuelang.org/go/cue"
- "cuelang.org/go/cue/cuecontext"
- "cuelang.org/go/encoding/openapi"
)
-func OpenAPIFromObject(obj Objecter) ([]byte, error) {
- cCtx := cuecontext.New()
- cueSpec, err := cueSpecFromObject(cCtx, obj)
- if err != nil {
- return nil, err
- }
-
- // We need to wrap the cue spec into a schema definition.
- defPath := cue.MakePath(cue.Def(obj.ObjectKind()))
- oapiSpec := cCtx.CompileString("{}").FillPath(defPath, cueSpec)
- bOpenAPI, err := openapi.Gen(oapiSpec, &openapi.Config{
- ExpandReferences: true,
- })
- if err != nil {
- return nil, fmt.Errorf("generating open api spec: %w", err)
- }
- return bOpenAPI, nil
-}
-
-func OpenAPISpecFromObject(obj Objecter) (*Spec, error) {
- bOpenAPI, err := OpenAPIFromObject(obj)
- if err != nil {
- return nil, err
- }
- spec := Spec{}
- if err := json.Unmarshal(bOpenAPI, &spec); err != nil {
- return nil, fmt.Errorf("unmarshalling open api spec: %w", err)
- }
- return &spec, nil
-}
-
type Spec struct {
Openapi string `json:"openapi"`
Info Info `json:"info"` // Required.
Components Components `json:"components,omitempty"`
}
-func (s Spec) Schema() (Schema, error) {
- if len(s.Components.Schemas) != 1 {
- return Schema{}, fmt.Errorf(
- "expected 1 schema, got %d",
- len(s.Components.Schemas),
- )
- }
- for key, schema := range s.Components.Schemas {
- schema.Key = key
- return schema, nil
- }
- return Schema{}, fmt.Errorf("no schema")
-}
-
type Info struct {
Title string `json:"title"`
Description string `json:"description,omitempty"`
diff --git a/pkg/schema/doc.go b/pkg/schema/doc.go
new file mode 100644
index 0000000..cd3bc00
--- /dev/null
+++ b/pkg/schema/doc.go
@@ -0,0 +1,3 @@
+// Package schema keeps a record of all the CustomResourceDefinitions and their
+// schemas.
+package schema
diff --git a/pkg/schema/schema.go b/pkg/schema/schema.go
new file mode 100644
index 0000000..a704164
--- /dev/null
+++ b/pkg/schema/schema.go
@@ -0,0 +1,134 @@
+package schema
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "sync"
+ "time"
+
+ "github.com/nats-io/nats.go"
+ "github.com/verifa/horizon/pkg/extensions/core"
+ "github.com/verifa/horizon/pkg/hz"
+ "github.com/verifa/horizon/pkg/internal/openapiv3"
+)
+
+type Schema struct {
+ Conn *nats.Conn
+
+ definitions map[string]*core.CustomResourceDefinition
+ crdWatcher *hz.Watcher
+ init bool
+ eventCh chan hz.Event
+ mx sync.RWMutex
+}
+
+// What does schema need to do?
+// Store receives commands, and needs to resolve the Kind.
+// Store receives command for providing the openapiv3 schema.
+
+// KindForKey returns the "real" Kind for the given key.
+// The key could be lowercase, plural, singular, short-hand, etc.
+// The key might not contain a group or version.
+func (s *Schema) KindForKey(ctx context.Context, key hz.ObjectKeyer) string {
+ s.mx.RLock()
+ defer s.mx.RUnlock()
+ // Check if the key is valid.
+ // Group, Version, Namespace and Name are exact matches.
+ // Kind could be lowercase, plural, singular, short-hand, etc.
+ // Return the "real" Kind.
+ return ""
+}
+
+func (s *Schema) IsKnownObject(ctx context.Context, key hz.ObjectKeyer) bool {
+ s.mx.RLock()
+ defer s.mx.RUnlock()
+ // Check if the object is known.
+ return true
+}
+
+func (s *Schema) OpenAPIV3Schema(
+ ctx context.Context,
+ key hz.ObjectKeyer,
+) (*openapiv3.Schema, error) {
+ // Get the OpenAPIV3 schema for the given key.
+ return nil, nil
+}
+
+func (s *Schema) Start(ctx context.Context) error {
+ s.eventCh = make(chan hz.Event)
+ go func() {
+ for event := range s.eventCh {
+ var result hz.Result
+ var err error
+ switch event.Key.ObjectKind() {
+ case core.ObjectKindCustomResourceDefinition:
+ result, err = s.handleCustomResourceDefinition(event)
+ default:
+ err = fmt.Errorf(
+ "unexpected object kind: %v",
+ event.Key.ObjectKind(),
+ )
+ }
+ if err := event.Respond(hz.EventResult{
+ Result: result,
+ Err: err,
+ }); err != nil {
+ slog.Error("responding to event", "err", err)
+ }
+ }
+ }()
+ //
+ // Start crd watcher
+ //
+ crdWatcher, err := hz.StartWatcher(
+ ctx,
+ s.Conn,
+ hz.WithWatcherFor(core.CustomResourceDefinition{}),
+ hz.WithWatcherCh(s.eventCh),
+ )
+ if err != nil {
+ return fmt.Errorf("starting crd watcher: %w", err)
+ }
+ s.crdWatcher = crdWatcher
+
+ // Wait for all watchers to initialize.
+ init := make(chan struct{})
+ go func() {
+ <-s.crdWatcher.Init
+ close(init)
+ }()
+
+ select {
+ case <-init:
+ // Do nothing and continue.
+ s.init = true
+ case <-time.After(5 * time.Second):
+ return fmt.Errorf("timed out waiting for watchers to initialize")
+ }
+
+ return nil
+}
+
+func (s *Schema) Close() error {
+ s.crdWatcher.Close()
+ close(s.eventCh)
+ return nil
+}
+
+func (s *Schema) handleCustomResourceDefinition(
+ event hz.Event,
+) (hz.Result, error) {
+ var crd core.CustomResourceDefinition
+ if err := json.Unmarshal(event.Data, &crd); err != nil {
+ return hz.Result{}, fmt.Errorf(
+ "unmarshalling custom resource definition: %w",
+ err,
+ )
+ }
+ s.mx.Lock()
+ defer s.mx.Unlock()
+ s.definitions[hz.KeyFromObject(crd)] = &crd
+ return hz.Result{}, nil
+}
diff --git a/pkg/server/server.go b/pkg/server/server.go
index b4a9d9f..56138a4 100644
--- a/pkg/server/server.go
+++ b/pkg/server/server.go
@@ -4,12 +4,12 @@ import (
"context"
"errors"
"fmt"
- "log/slog"
"github.com/nats-io/jwt/v2"
"github.com/nats-io/nats.go"
"github.com/verifa/horizon/pkg/auth"
"github.com/verifa/horizon/pkg/broker"
+ "github.com/verifa/horizon/pkg/controller"
"github.com/verifa/horizon/pkg/extensions/core"
"github.com/verifa/horizon/pkg/gateway"
"github.com/verifa/horizon/pkg/hz"
@@ -20,16 +20,6 @@ import (
func WithDevMode() ServerOption {
return func(o *serverOptions) {
o.devMode = true
-
- o.runNATSServer = true
- o.runAuth = true
- o.runBroker = true
- o.runStore = true
- o.runGateway = true
-
- o.runSecretsController = true
- o.runNamespaceController = true
- o.runPortalController = true
}
}
@@ -110,15 +100,16 @@ type serverOptions struct {
runStore bool
runGateway bool
- runSecretsController bool
- runNamespaceController bool
- runPortalController bool
+ runCustomResourceDefinitionController bool
+ runSecretsController bool
+ runNamespaceController bool
+ runPortalController bool
natsOptions []natsutil.ServerOption
authOptions []auth.Option
storeOptions []store.StoreOption
gatewayOptions []gateway.ServerOption
- namespaceControllerOptions []hz.ControllerOption
+ namespaceControllerOptions []controller.Option
}
type Server struct {
@@ -130,9 +121,10 @@ type Server struct {
Store *store.Store
Gateway *gateway.Server
- CtlrSecrets *hz.Controller
- CtlrNamespaces *hz.Controller
- CltrPortals *hz.Controller
+ CtlrCustomResourceDefinitions *controller.Controller
+ CtlrSecrets *controller.Controller
+ CtlrNamespaces *controller.Controller
+ CltrPortals *controller.Controller
}
func Start(
@@ -148,7 +140,18 @@ func Start(
}
func (s *Server) Start(ctx context.Context, opts ...ServerOption) error {
- opt := serverOptions{}
+ opt := serverOptions{
+ runNATSServer: true,
+ runAuth: true,
+ runBroker: true,
+ runStore: true,
+ runGateway: true,
+
+ runCustomResourceDefinitionController: true,
+ runSecretsController: true,
+ runNamespaceController: true,
+ runPortalController: true,
+ }
for _, o := range opts {
o(&opt)
}
@@ -181,6 +184,32 @@ func (s *Server) Start(ctx context.Context, opts ...ServerOption) error {
if err := store.InitKeyValue(ctx, s.Conn, opt.storeOptions...); err != nil {
return fmt.Errorf("initializing key value store: %w", err)
}
+ if opt.runStore {
+ store, err := store.Start(ctx, s.Conn, s.Auth, opt.storeOptions...)
+ if err != nil {
+ return fmt.Errorf("starting store: %w", err)
+ }
+ s.Store = store
+ }
+
+ if opt.runCustomResourceDefinitionController {
+ ctlr, err := controller.Start(
+ ctx,
+ s.Conn,
+ controller.WithFor(core.CustomResourceDefinition{}),
+ controller.WithValidatorCUE(false),
+ controller.WithValidator(
+ &hz.ValidateNamespaceRoot{},
+ ),
+ controller.WithValidator(
+ &core.CustomResourceDefinitionValidate{},
+ ),
+ )
+ if err != nil {
+ return fmt.Errorf("starting crd controller: %w", err)
+ }
+ s.CtlrCustomResourceDefinitions = ctlr
+ }
if opt.runAuth {
auth, err := auth.Start(ctx, s.Conn, opt.authOptions...)
@@ -194,13 +223,6 @@ func (s *Server) Start(ctx context.Context, opts ...ServerOption) error {
return errors.New("auth service/component required")
}
- if opt.runStore {
- store, err := store.StartStore(ctx, s.Conn, s.Auth, opt.storeOptions...)
- if err != nil {
- return fmt.Errorf("starting store: %w", err)
- }
- s.Store = store
- }
if opt.runBroker {
broker := broker.Broker{
Conn: s.Conn,
@@ -220,10 +242,10 @@ func (s *Server) Start(ctx context.Context, opts ...ServerOption) error {
}
if opt.runSecretsController {
- ctlr, err := hz.StartController(
+ ctlr, err := controller.Start(
ctx,
s.Conn,
- hz.WithControllerFor(core.Secret{}),
+ controller.WithFor(core.Secret{}),
)
if err != nil {
return fmt.Errorf("starting secrets controller: %w", err)
@@ -231,11 +253,12 @@ func (s *Server) Start(ctx context.Context, opts ...ServerOption) error {
s.CtlrSecrets = ctlr
}
if opt.runNamespaceController {
- defaultOptions := []hz.ControllerOption{
- hz.WithControllerFor(core.Namespace{}),
+ defaultOptions := []controller.Option{
+ controller.WithFor(core.Namespace{}),
+ controller.WithValidator(&hz.ValidateNamespaceRoot{}),
}
- ctlr, err := hz.StartController(
+ ctlr, err := controller.Start(
ctx,
s.Conn,
append(defaultOptions, opt.namespaceControllerOptions...)...,
@@ -246,10 +269,11 @@ func (s *Server) Start(ctx context.Context, opts ...ServerOption) error {
s.CtlrNamespaces = ctlr
}
if opt.runPortalController {
- ctlr, err := hz.StartController(
+ ctlr, err := controller.Start(
ctx,
s.Conn,
- hz.WithControllerFor(hz.Portal{}),
+ controller.WithFor(hz.Portal{}),
+ controller.WithValidator(&hz.ValidateNamespaceRoot{}),
)
if err != nil {
return fmt.Errorf("starting portal controller: %w", err)
@@ -257,18 +281,6 @@ func (s *Server) Start(ctx context.Context, opts ...ServerOption) error {
s.CltrPortals = ctlr
}
- // Check that the root namespace exists as an object.
- // This is a little bit fidgety, because the root account *will* exist in
- // NATS, but we want it to exist as a horizon namespace object in the store.
- // We cannot create the horizon object when we create the account in nats
- // because we would need the store to run, which cannot run until the root
- // account exists in nats...
- // For now, create it here but when we split the server out we'll need to
- // find a good startup process.
- if err := s.checkRootNamespaceObject(ctx); err != nil {
- return fmt.Errorf("checking root namespace object: %w", err)
- }
-
if opt.devMode {
userConfig, err := jwt.FormatUserConfig(
s.NS.Auth.RootUser.JWT,
@@ -341,24 +353,3 @@ func (s *Server) Close() error {
}
return errs
}
-
-func (s *Server) checkRootNamespaceObject(
- ctx context.Context,
-) error {
- nsClient := hz.ObjectClient[core.Namespace]{
- Client: hz.NewClient(s.Conn, hz.WithClientInternal(true)),
- }
- applyOp, err := nsClient.Apply(ctx, core.Namespace{
- ObjectMeta: hz.ObjectMeta{
- Name: hz.NamespaceRoot,
- Namespace: hz.NamespaceRoot,
- },
- Spec: &core.NamespaceSpec{},
- Status: &core.NamespaceStatus{},
- })
- if err != nil {
- return fmt.Errorf("apply root namespace: %w", err)
- }
- slog.Info("applied root namespace", "op", applyOp)
- return nil
-}
diff --git a/pkg/store/list_test.go b/pkg/store/list_test.go
index d808285..19248e0 100644
--- a/pkg/store/list_test.go
+++ b/pkg/store/list_test.go
@@ -6,6 +6,7 @@ import (
"testing"
"github.com/google/go-cmp/cmp"
+ "github.com/verifa/horizon/pkg/controller"
"github.com/verifa/horizon/pkg/hz"
"github.com/verifa/horizon/pkg/internal/managedfields"
"github.com/verifa/horizon/pkg/server"
@@ -27,10 +28,10 @@ func TestList(t *testing.T) {
ti := server.Test(t, ctx)
// SETUP DUMMY CONTROLLER
- ctlr, err := hz.StartController(
+ ctlr, err := controller.Start(
ctx,
ti.Conn,
- hz.WithControllerFor(DummyApplyObject{}),
+ controller.WithFor(DummyApplyObject{}),
)
tu.AssertNoError(t, err)
t.Cleanup(func() {
diff --git a/pkg/store/store.go b/pkg/store/store.go
index 01ff057..37e2b3e 100644
--- a/pkg/store/store.go
+++ b/pkg/store/store.go
@@ -72,7 +72,7 @@ type storeOptions struct {
stopTimeout time.Duration
}
-func StartStore(
+func Start(
ctx context.Context,
conn *nats.Conn,
auth *auth.Auth,
@@ -177,6 +177,10 @@ func (s *Store) Start(
return fmt.Errorf("start garbage collector: %w", err)
}
s.gc = gc
+
+ if err := s.applyRootNamespaceObject(ctx); err != nil {
+ return fmt.Errorf("applying root namespace object: %w", err)
+ }
return nil
}
@@ -202,6 +206,35 @@ func (s *Store) Close() error {
return errs
}
+func (s *Store) applyRootNamespaceObject(
+ ctx context.Context,
+) error {
+ ns := core.Namespace{
+ ObjectMeta: hz.ObjectMeta{
+ Name: hz.NamespaceRoot,
+ Namespace: hz.NamespaceRoot,
+ },
+ Spec: &core.NamespaceSpec{},
+ Status: &core.NamespaceStatus{},
+ }
+ data, err := json.Marshal(ns)
+ if err != nil {
+ return fmt.Errorf("marshalling root namespace: %w", err)
+ }
+ if _, err := s.kv.Put(ctx, hz.KeyFromObject(ns), data); err != nil {
+ return fmt.Errorf("put root namespace: %w", err)
+ }
+ // if _, err := s.Apply(ctx, ApplyRequest{
+ // Key: hz.ObjectKeyFromObject(ns),
+ // Data: data,
+ // Manager: "hz-store",
+ // Force: true,
+ // }); err != nil {
+ // return fmt.Errorf("apply root namespace: %w", err)
+ // }
+ return nil
+}
+
func (s *Store) stopWaitTimeout() bool {
done := make(chan struct{})
go func() {
@@ -412,7 +445,7 @@ func (s *Store) handleInternalMsg(ctx context.Context, msg *nats.Msg) {
}
return nil
}
- if !isNamespaceKey(key) {
+ if !isKindNamespace(key) {
err := checkNamespace(key.Namespace)
if err != nil {
_ = hz.RespondError(msg, err)
@@ -504,7 +537,7 @@ func removeReadOnlyFields(data []byte) ([]byte, error) {
return sjson.DeleteBytes(data, "metadata.revision")
}
-func isNamespaceKey(key hz.ObjectKeyer) bool {
+func isKindNamespace(key hz.ObjectKeyer) bool {
return key.ObjectGroup() == core.ObjectGroup &&
key.ObjectKind() == core.ObjectKindNamespace
}
diff --git a/pkg/store/store_test.go b/pkg/store/store_test.go
index e4302c3..971823c 100644
--- a/pkg/store/store_test.go
+++ b/pkg/store/store_test.go
@@ -10,6 +10,7 @@ import (
"time"
"github.com/google/go-cmp/cmp"
+ "github.com/verifa/horizon/pkg/controller"
"github.com/verifa/horizon/pkg/hz"
"github.com/verifa/horizon/pkg/server"
"github.com/verifa/horizon/pkg/store"
@@ -78,12 +79,11 @@ func TestStore(t *testing.T) {
for _, txtarFile := range txtarFiles {
ti := server.Test(t, ctx)
// SETUP DUMMY CONTROLLER
- ctlr, err := hz.StartController(
+ ctlr, err := controller.Start(
ctx,
ti.Conn,
- hz.WithControllerFor(DummyApplyObject{}),
- hz.WithControllerValidatorCUE(false),
- hz.WithControllerValidatorForceNone(),
+ controller.WithFor(DummyApplyObject{}),
+ controller.WithValidatorCUE(false),
)
tu.AssertNoError(t, err)
t.Cleanup(func() {