diff --git a/Makefile b/Makefile index 860c9ad..f1b8f3d 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,9 @@ -CI_CMD := go run ./cmd/ci/*.go +CI_CMD := go run ./cmd/ci/ci.go + +.PHONY: generate +generate: + $(CI_CMD) -generate .PHONY: pr pr: diff --git a/README.md b/README.md index fcab268..e272d47 100644 --- a/README.md +++ b/README.md @@ -47,105 +47,19 @@ Below is a high-level overview of the different Horizon components. For a more d > [!NOTE] > All communication is handled via NATS. As such, the dotted lines between components do not actually exist but are subject-based pub/subs via NATS. -Your end users will typically interact with Horizon via HTTP servers (portals) or command line tools that the platform team build. More on [portals](#platform---portals). +Your end users will typically interact with Horizon via HTTP servers (portals) or command line tools that the platform team build. More on [portals](./docs/architecture.md#platform---portals). -For provisioning "resources" (e.g. cloud infrastructure, Git repositories, artifact respositories) the platform team will define objects and a controller to handle reconciliation (taking an object specifiation and moving the object towards the desired state). More on [controllers](#platform---controllers). +For provisioning "resources" (e.g. cloud infrastructure, Git repositories, artifact respositories) the platform team will define objects and a controller to handle reconciliation (taking an object specifiation and moving the object towards the desired state). More on [controllers](./docs/architecture.md#platform---controllers). -For running operations across different nodes, actors can be called via the broker and actors are selected based on their labels. More on [actors](#platform---actors). +For running operations across different nodes, actors can be called via the broker and actors are selected based on their labels. More on [actors](./docs/architecture.md#platform---actors). ## Architecture -This section describes the different components of Horizon to build a platform. - -### Core - -The "core" consists of a [NATS](https://nats.io/) server and some internal services. - -#### Core - NATS - -Horizon requires a NATS server. Horizon makes heavy use of NATS: basic subject based pub/sub, accounts for multitenancy, streams and consumers for controllers and the Key-Value store for storing the objects (a NATS KV is actually just a glorified stream in the end). - -You do not need to know NATS to get started with Horizon, but if you want to get serious with Horizon you should learn enough about NATS to debug any issues. -Horizon does not try to hide away the NATS abstractions. -Therefore if anything goes wrong, you can always connect to NATS directly for debugging. -Or if you create lots of data in Horizon and decide to migrate away, your data is readily available in NATS. - -#### Core - Store - -The `store` is a service that handles all server-side operations for objects in the NATS KV. - -No other service is expected to interface directly with the NATS KV (except for controllers that create NATS consumers for the underlying KV stream). - -The store provides basic CRUD operations (`create`, `get`, `list`, `update` and `delete`), as well as `validate` and `apply`. -The noteworthy operation is the `apply` because this works like Kubernetes' [server-side apply](https://kubernetes.io/docs/reference/using-api/server-side-apply/). - -As objects in the store will be mutated by users and controllers, the server-side apply controls which fields are *owned* by the different entities. -Reading the Kubernetes documentation will give you a greater understanding of how this works. -Note that Horizon does not support client-side apply (of course you could write your own and use a store `update` operation if you really wanted to...). - -The store defers all validation requests to the controller for an object. - -#### Core - Gateway - -The `gateway` is the single HTTP endpoint of a Horizon deployment. -It is the entrypoint (or gateway) into Horizon for end users. - -It handles authentication (OIDC) and authorization (RBAC). -Portals are used to extend the HTTP-based UI and the gateway uses an HTTP-to-NATS proxy for serving requests to the portal. -After all, portals are just HTTP servers connected via NATS and all requests go via the gateway. -Portal HTTP handlers are expected to user server-side rendering using libraries like [htmx](https://htmx.org/). - -The gateway is not very complex, and much of it can be re-used so building your own gateway is justifiable if you want complete control. - -#### Core - Broker - -The `broker` is a service that handles actor run requests. - -Upon receiving a run request, it advertises the request to all actors and forwards the request on to the first actor that responds. -Actors can choose whether to accept a request based on label selectors or any other filtering technique you want to use. - -### Platform - -The "platform" layer contains all the components that the platform team will develop to make Horizon actually do something! - -#### Platform - Portals - -Portals are how the Horizon web UI is extended. - -Portals are just HTTP servers connected to NATS and the gateway proxies HTTP requests over NATS to portals. - -The goal is to have a single user-facing HTTP endpoing (i.e. the gateway) and as many portals as you need, all accessible under that one endpoint. - -Typically your portal HTTP servers will render HTML and use a library like [htmx](https://htmx.org/) to modify the HTML on the client-side. -As the portals are just HTTP servers you can develop whatever you want, as long as it is HTTP-based (like JSON REST APIs). - -#### Platform - Controllers - -Controllers are similar to Kubernetes controllers. -A controller requires you to define an object that it controls, and will perform validation and reconciliation of that object. - -`Reconcilers` take an object specifiation and move the object towards the desired state. -This is handled by a reconcile loop, which you can implement. -Under the hood, a controller creates a [NATS consumer](https://docs.nats.io/nats-concepts/jetstream/consumers) that gets notified about objects in the NATS KV store. - -`Validators` validate objects as they are added to the KV store. -This is similar to Kubernetes' [Admission Controllers](https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/), but instead of config languages you write code to validate objects. -By default, NATS uses [CUE](https://cuelang.org/) to validate objects and you can write custom validate functions as well. - -#### Platform - Actors - -Actors enable you to write synchronous actions that operate on objects. -Actions do not require any persistence, but can interact with any persistence layer (like the NATS KV store). - -Actors provide a broker mechanism for selecting an appropriate instance of an actor to run the action on. -For example, when scheduling a container you want it to run on a specific node. -Actors allow you to define an action such as `RunContainer` and the broker will ensure (based on label selection) that the relevant actors run the action. - -Unless you are doing things that are node-dependent (like running containers, or executing CLIs that require specific tooling), you might not need actors at all. +Check the [architecture](./docs/architecture.md) document for some more information on the different components. ## Getting started -TODO. +Check the [getting started](./docs/gettingstarted.md) section. ## Examples diff --git a/cmd/ci/ci.go b/cmd/ci/ci.go index 3d0cc98..bbd020b 100644 --- a/cmd/ci/ci.go +++ b/cmd/ci/ci.go @@ -25,8 +25,8 @@ const ( ) func main() { - var build, lint, test, pr bool - flag.BoolVar(&build, "build", false, "build the website locally") + var generate, lint, test, pr bool + flag.BoolVar(&generate, "generate", false, "generate code") flag.BoolVar(&lint, "lint", false, "lint the code") flag.BoolVar(&test, "test", false, "run the tests") flag.BoolVar(&pr, "pr", false, "run the pull request checks") @@ -50,16 +50,15 @@ func main() { ) defer stop() + if generate { + Generate(ctx) + } if lint { Lint(ctx) } if test { Test(ctx) } - if build { - panic("build: not implemented") - // _ = KoBuild(ctx, WithKoLocal()) - } if pr { PullRequest(ctx) } diff --git a/cmd/horizon/main.go b/cmd/horizon/horizon.go similarity index 67% rename from cmd/horizon/main.go rename to cmd/horizon/horizon.go index 7ca5e47..e74fa01 100644 --- a/cmd/horizon/main.go +++ b/cmd/horizon/horizon.go @@ -24,22 +24,12 @@ func run() error { ctx, server.WithDevMode(), server.WithAuthOptions(auth.WithAdminGroups("admin")), - // server.WithGatewayOptions( - // gateway.WithOIDCConfig( - // gateway.OIDCConfig{ - // Issuer: "http://localhost:9998/", - // ClientID: "web", - // ClientSecret: "secret", - // RedirectURL: "http://localhost:9999/auth/callback", - // }, - // ), - // ), ) if err != nil { return err } defer s.Close() - slog.Info("horizon server started", "services", s.Services()) + slog.Info("horizon server started") <-ctx.Done() // Stop listening for interrupts so that a second interrupt will force diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..d6b4716 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,89 @@ +# Architecture + +This section describes the different components of Horizon to build a platform. + +## Core + +The "core" consists of a [NATS](https://nats.io/) server and some internal services. + +### Core - NATS + +Horizon requires a NATS server. Horizon makes heavy use of NATS: basic subject based pub/sub, accounts for multitenancy, streams and consumers for controllers and the Key-Value store for storing the objects (a NATS KV is actually just a glorified stream in the end). + +You do not need to know NATS to get started with Horizon, but if you want to get serious with Horizon you should learn enough about NATS to debug any issues. +Horizon does not try to hide away the NATS abstractions. +Therefore if anything goes wrong, you can always connect to NATS directly for debugging. +Or if you create lots of data in Horizon and decide to migrate away, your data is readily available in NATS. + +### Core - Store + +The `store` is a service that handles all server-side operations for objects in the NATS KV. + +No other service is expected to interface directly with the NATS KV (except for controllers that create NATS consumers for the underlying KV stream). + +The store provides basic CRUD operations (`create`, `get`, `list`, `update` and `delete`), as well as `validate` and `apply`. +The noteworthy operation is the `apply` because this works like Kubernetes' [server-side apply](https://kubernetes.io/docs/reference/using-api/server-side-apply/). + +As objects in the store will be mutated by users and controllers, the server-side apply controls which fields are *owned* by the different entities. +Reading the Kubernetes documentation will give you a greater understanding of how this works. +Note that Horizon does not support client-side apply (of course you could write your own and use a store `update` operation if you really wanted to...). + +The store defers all validation requests to the controller for an object. + +### Core - Gateway + +The `gateway` is the single HTTP endpoint of a Horizon deployment. +It is the entrypoint (or gateway) into Horizon for end users. + +It handles authentication (OIDC) and authorization (RBAC). +Portals are used to extend the HTTP-based UI and the gateway uses an HTTP-to-NATS proxy for serving requests to the portal. +After all, portals are just HTTP servers connected via NATS and all requests go via the gateway. +Portal HTTP handlers are expected to user server-side rendering using libraries like [htmx](https://htmx.org/). + +The gateway is not very complex, and much of it can be re-used so building your own gateway is justifiable if you want complete control. + +### Core - Broker + +The `broker` is a service that handles actor run requests. + +Upon receiving a run request, it advertises the request to all actors and forwards the request on to the first actor that responds. +Actors can choose whether to accept a request based on label selectors or any other filtering technique you want to use. + +## Platform + +The "platform" layer contains all the components that the platform team will develop to make Horizon actually do something! + +### Platform - Portals + +Portals are how the Horizon web UI is extended. + +Portals are just HTTP servers connected to NATS and the gateway proxies HTTP requests over NATS to portals. + +The goal is to have a single user-facing HTTP endpoing (i.e. the gateway) and as many portals as you need, all accessible under that one endpoint. + +Typically your portal HTTP servers will render HTML and use a library like [htmx](https://htmx.org/) to modify the HTML on the client-side. +As the portals are just HTTP servers you can develop whatever you want, as long as it is HTTP-based (like JSON REST APIs). + +### Platform - Controllers + +Controllers are similar to Kubernetes controllers. +A controller requires you to define an object that it controls, and will perform validation and reconciliation of that object. + +`Reconcilers` take an object specifiation and move the object towards the desired state. +This is handled by a reconcile loop, which you can implement. +Under the hood, a controller creates a [NATS consumer](https://docs.nats.io/nats-concepts/jetstream/consumers) that gets notified about objects in the NATS KV store. + +`Validators` validate objects as they are added to the KV store. +This is similar to Kubernetes' [Admission Controllers](https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/), but instead of config languages you write code to validate objects. +By default, NATS uses [CUE](https://cuelang.org/) to validate objects and you can write custom validate functions as well. + +### Platform - Actors + +Actors enable you to write synchronous actions that operate on objects. +Actions do not require any persistence, but can interact with any persistence layer (like the NATS KV store). + +Actors provide a broker mechanism for selecting an appropriate instance of an actor to run the action on. +For example, when scheduling a container you want it to run on a specific node. +Actors allow you to define an action such as `RunContainer` and the broker will ensure (based on label selection) that the relevant actors run the action. + +Unless you are doing things that are node-dependent (like running containers, or executing CLIs that require specific tooling), you might not need actors at all. diff --git a/docs/controllers.md b/docs/controllers.md new file mode 100644 index 0000000..1ff071d --- /dev/null +++ b/docs/controllers.md @@ -0,0 +1,4 @@ +# Controllers + +TODO: tell more intricate details about controllers and writing them. +How do they work under the hood (i.e. NATS consumers), how can they be configured, etc. diff --git a/docs/gettingstarted.md b/docs/gettingstarted.md new file mode 100644 index 0000000..57ed447 --- /dev/null +++ b/docs/gettingstarted.md @@ -0,0 +1,295 @@ +# Getting Started + +This getting started guide is intended for "platform teams" looking to develop their internal developer platform on top of Horizon. + +We will go over the basics to: + +1. Run a local dev Horizon server +2. Develop an extension that includes + - An API object + - A controller + - A web portal + - An actor + +If you do not know what these things are, please refer to the [architecture](./architecture.md). + +## 1. Running a local dev Horizon server + +Clone this repository, run the code generation (templ) and run the horizon server, e.g.: + +```console +git clone git@github.com:verifa/horizon.git +cd horizon + +# Run code generation. +go run ./cmd/ci/ci.go -generate + +# Run the horizon server. +go run ./cmd/horizon/horizon.go +``` + +If all works, you should see the following output: + +```console + _ _ +| |__ ___ _ __(_)_______ _ __ +| '_ \ / _ \| '__| |_ / _ \| '_ \ +| | | | (_) | | | |/ / (_) | | | | +|_| |_|\___/|_| |_/___\___/|_| |_| + _ _ + __| |_____ __ _ __ ___ __| |___ +/ _` / -_) V / | ' \/ _ \/ _` / -_) +\__,_\___|\_/ |_|_|_\___/\__,_\___| + +Below is a NATS credential for the root account. +Copy it to a file such as nats.creds + + +-----BEGIN NATS USER JWT----- +... +------END NATS USER JWT------ + +************************* IMPORTANT ************************* +NKEY Seed printed below can be used to sign and prove identity. +NKEYs are sensitive and should be treated as secrets. + +-----BEGIN USER NKEY SEED----- +... +------END USER NKEY SEED------ + +************************************************************* +``` + +Copy this NATS credential and put it into a file, such as `nats.creds` as we will need this when developing our Horizon extension in the next part. + +Open your browser at and you should be redirected to login. + +Login with username `admin` and password `admin`. +Horizon comes with an embedded OIDC provider based on [zitadel/oidc](https://github.com/zitadel/oidc/tree/main/example/server) and is pre-populated with these credentials. + +## 2. Developing a Horizon extension + +There is no strict definition of a Horizon extension. +It could be just a web portal or an object definition, or both and a controller and actor. + +For the example, we will rewrite the greetings example that can be found under the [examples](../examples/greetings/). + +You could fork the horizon project and make your own example, or create a separate Go module and create an extension there. + +Let's start by defining our object. + +### 2.1 Defining an object + +Objects in Horizon are indexed in the NATS KV with a *key* (a NATS subject relative to the NATS KV). +The key includes the following fields: + +1. **Object Group:** groups are a logical way to organise resources together for things like searching and RBAC. +2. **Object Version:** the object version is a way to version the API. It helps maintain things like backwards compatability. +3. **Object Kind:** is just a name for the kind of object. +4. **Object Account:** is the account that this object belongs to. +5. **Object Name:** is the unique identifier for this object within the account. + +An example key looks like: `group.v1.Object.account.name`. + +There are two important interfaces in the `hz` package: + +```go +// ObjectKeyer is an interface that can produce a unique key for an object. +type ObjectKeyer interface { + ObjectGroup() string + ObjectVersion() string + ObjectKind() string + ObjectAccount() string + ObjectName() string +} + +// Objecter is an interface that represents an object in the Horizon API. +type Objecter interface { + ObjectKeyer + ObjectRevision() *uint64 + ObjectDeletionTimestamp() *Time + ObjectOwnerReferences() []OwnerReference + ObjectOwnerReference(Objecter) (OwnerReference, bool) + ObjectManagedFields() managedfields.ManagedFields +} +``` + +They serve two different use cases. If you want to act on an object in Horizon, you need only something that implements `hz.ObjectKeyer` (like `hz.ObjectKey`). +To define an object we need a struct that implements `hz.Objecter`. +Any struct that implements `hz.Objecter` will also implement `hz.ObjectKeyer`. + +Let's say we define an object `Greeting` as follows: + +```go +// Compiler check that Greeting implements hz.Objecter. +var _ hz.Objecter = (*Greeting)(nil) + +type Greeting struct { + hz.ObjectMeta `json:"metadata" cue:""` + + // Add custom fields here. + // Convention is to use a spec and status field. + Spec *GreetingSpec `json:"spec,omitempty" cue:""` + Status *GreetingStatus `json:"status,omitempty"` +} + +func (s Greeting) ObjectGroup() string { + return "greetings" +} + +func (s Greeting) ObjectVersion() string { + return "v1" +} + +func (s Greeting) ObjectKind() string { + return "Greeting" +} + +// GreetingSpec defines the desired state of Greeting. +type GreetingSpec struct { + // Name of the person to greet. + Name string `json:"name,omitempty" cue:""` +} + +// GreetingStatus defines the observed state of Greeting. +type GreetingStatus struct { + // Ready indicates whether the greeting is ready. + Ready bool `json:"ready"` + // Error is the error message if the greeting failed. + Error string `json:"error,omitempty" cue:",opt"` + // Response is the response of the greeting. + Response string `json:"response,omitempty" cue:",opt"` +} +``` + +That's it. Now we have an object, but for someone to be able to create an object in the NATS KV store we need to start a controller which handles validation and reconciliation of all greetings. + +### 2.2 Creating a controller + +Whenever a greeting is created we want our controller for the greeting object to reconcile and add a response to the `.status.response` field. + +This controller does not do very much but its intention is to teach you the basics of Horizon. +For a real example, we might use a cloud SDK or call Terraform from our Go code to provision some resources. + +To start a controller we will call the `hz.StartController(...)` function. +A controller does actually not need a reconciler, nor a validator. +A controller does need an object though, and from that object a NATS key will be calculated which determines which objects will be reconciled. +For example, if we do the following we will start a controller that will reconcile objects with the key `greetings.v1.Greeting.*.*`, where `*` is a wildcard to match any string. + +```go +ctlr, err := hz.StartController( + ctx, + conn, + hz.WithControllerFor(greetings.Greeting{}), + ) +``` + +This key will match all objects with the kind `Greeting`, for `v1` in the `greetings` group in any account with any name. + +#### 2.2.1 Creating a reconciler + +To define a reconciler we need to implement the `hz.Reconciler` interface: + +```go +type Reconciler interface { + Reconcile(context.Context, Request) (Result, error) +} +``` + +We can do so with a struct as follows: + +```go +type GreetingReconciler struct {} + +// Reconcile implements hz.Reconciler. +func (r *GreetingReconciler) Reconcile( + ctx context.Context, + req hz.Request, +) (hz.Result, error) { + // TODO: the actual reconcile logic here. + return hz.Result{}, nil +} +``` + +You can see the full example in [reconciler.go](../examples/greetings/reconciler.go). + +#### 2.2.2 Creating a validator + +TODO: once validator supports create/update/delete validation. + +You can see the full example in [validator.go](../examples/greetings/validator.go). + +#### 2.2.3 Running our controller + +Look at [greetings.go](../examples/greetings/cmd/greetings.go) for an example of how to start a controller. + +You can run multiple controllers in the same binary, so don't feel like you have to create a separate binary for every controller. + +### 2.3 Creating a portal + +A portal in Horizon simply subscribes to a NATS subject that receives HTTP messages (as `[]byte`). +It converts each message into a `http.Request` and sends it to your `http.Handler` that you can implement however you want (e.g. Go stdlib, [Chi](https://github.com/go-chi/chi) or [Echo](https://github.com/labstack/echo)). + +We start the portal by calling `hz.StartPortal(...)` which handles all the NATS subscriptions and conversion of NATS messages to `http.Request` and calling your `handler.ServeHTTP(w htt.ResponseWriter, r *http.Request)`. + +All you need to do is write a Go server! + +Here's the most minimal example imagineable: + +```go +ctx := context.Background() +conn, _ := nats.Connect(nats.DefaultURL) +portalObj := hz.Portal{ + ObjectMeta: hz.ObjectMeta{ + Account: hz.RootAccount, + Name: "greetings", + }, + Spec: &hz.PortalSpec{ + DisplayName: "Greetings", + }, +} +mux := http.NewServeMux() +portal, err := hz.StartPortal(ctx, conn, portalObj, mux) +``` + +We have been having a great time with [Templ](https://templ.guide/) and [HTMX](https://htmx.org/) for building portals. + +Take a look at the greetings [portal.go](../examples/greetings/portal.go). + +### 2.4 Testing your extension + +Testing was one of the major reasons why we accidently started building Horizon. +Having working with Kubernetes controllers we found the testing phase to be... lacking developer experience. + +One major advantage of NATS, and therefore Horizon, is that we can easily embed a NATS server into our Go binaries, and therefore our tests! + +To make it nicer, we wrapped this into a nice package. + +To start a test Horizon server, with NATS and all the core components, just do this: + +```go +import ( + "context" + "testing" + "github.com/verifa/horizon/pkg/server" +) + +func TestGreeting(t *testing.T) { + ctx := context.Background() + // Create a test server which includes the core of Horizon. + ts := server.Test(t, ctx) + // Rest of the test here... +} +``` + +That's it! Look at the greetings [test](../examples/greetings/greetings_test.go) for more inspiration. + +## 3. Next steps + +Now that you have an idea of how to write controllers and portals, the best idea is to think of a simple use case you have in mind. + +Try to define an object that represents how your users would think about it. +Have a quick read up on Domain Driven Design and about using language that your users would understand. +I.e. **do not** copy the interface of whatever service you are interfacing over into Horizon - that would be pointless. + +It is also very important that you understand the why and how of [server side apply](./serversideapply.md). It is important when modelling your objects and writing controllers. diff --git a/docs/serversideapply.md b/docs/serversideapply.md new file mode 100644 index 0000000..a21b6cf --- /dev/null +++ b/docs/serversideapply.md @@ -0,0 +1,6 @@ +# Server Side Apply + +TODO: +[] General intro to *why* server side apply +[] What happens when you apply. +[] `hz.ExtractManagedFields` in controllers... diff --git a/examples/greetings/actor.go b/examples/greetings/actor.go index 5ebe3d8..5950d16 100644 --- a/examples/greetings/actor.go +++ b/examples/greetings/actor.go @@ -26,8 +26,7 @@ func (a GreetingsHelloAction) Do( } greeting.Status = &GreetingStatus{ Ready: true, - Phase: StatusPhaseCompleted, - Response: "Greetings, " + *greeting.Spec.Name + "!", + Response: "Greetings, " + greeting.Spec.Name + "!", } return greeting, nil } @@ -36,11 +35,11 @@ func (a GreetingsHelloAction) validate(greeting Greeting) error { if greeting.Spec == nil { return fmt.Errorf("spec is required") } - if greeting.Spec.Name == nil { + if greeting.Spec.Name == "" { return fmt.Errorf("name is required") } - if !isFriend(*greeting.Spec.Name) { + if !isFriend(greeting.Spec.Name) { return fmt.Errorf( "we don't greet strangers in Finland, we only know: %v", friends, diff --git a/examples/greetings/cmd/main.go b/examples/greetings/cmd/greetings.go similarity index 95% rename from examples/greetings/cmd/main.go rename to examples/greetings/cmd/greetings.go index b8637db..978f364 100644 --- a/examples/greetings/cmd/main.go +++ b/examples/greetings/cmd/greetings.go @@ -20,6 +20,7 @@ func main() { } func run() error { + // Establish a connection to the NATS server. conn, err := nats.Connect( nats.DefaultURL, nats.UserCredentials("nats.creds"), @@ -28,7 +29,7 @@ func run() error { return fmt.Errorf("connect: %w", err) } ctx := context.Background() - + // Handle interrupts. ctx, stop := signal.NotifyContext(ctx, os.Interrupt) defer stop() @@ -47,7 +48,6 @@ func run() error { }() validator := greetings.GreetingValidator{} - reconciler := greetings.GreetingReconciler{ GreetingClient: hz.ObjectClient[greetings.Greeting]{ Client: hz.NewClient( diff --git a/examples/greetings/greetings.go b/examples/greetings/greetings.go index 4e37d87..3718b2e 100644 --- a/examples/greetings/greetings.go +++ b/examples/greetings/greetings.go @@ -5,14 +5,14 @@ import "github.com/verifa/horizon/pkg/hz" var _ hz.Objecter = (*Greeting)(nil) type Greeting struct { - hz.ObjectMeta `json:"metadata"` + hz.ObjectMeta `json:"metadata" cue:""` - Spec *GreetingSpec `json:"spec,omitempty"` + Spec *GreetingSpec `json:"spec,omitempty" cue:""` Status *GreetingStatus `json:"status,omitempty"` } func (s Greeting) ObjectGroup() string { - return "hz-examples" + return "greetings" } func (s Greeting) ObjectVersion() string { @@ -23,24 +23,18 @@ func (s Greeting) ObjectKind() string { return "Greeting" } +// GreetingSpec defines the desired state of Greeting. type GreetingSpec struct { // Name of the person to greet. - Name *string `json:"name,omitempty" cue:""` + Name string `json:"name,omitempty" cue:""` } +// GreetingStatus defines the observed state of Greeting. type GreetingStatus struct { - Ready bool `json:"ready"` - Phase StatusPhase `json:"phase"` - FailureReason string `json:"failureReason"` - FailureMessage string `json:"failureMessage"` - - Response string `json:"response,omitempty"` + // Ready indicates whether the greeting is ready. + Ready bool `json:"ready"` + // Error is the error message if the greeting failed. + Error string `json:"error,omitempty" cue:",opt"` + // Response is the response of the greeting. + Response string `json:"response,omitempty" cue:",opt"` } - -type StatusPhase string - -const ( - StatusPhasePending StatusPhase = "Pending" - StatusPhaseCompleted StatusPhase = "Completed" - StatusPhaseFailed StatusPhase = "Failed" -) diff --git a/examples/greetings/examples_test.go b/examples/greetings/greetings_test.go similarity index 64% rename from examples/greetings/examples_test.go rename to examples/greetings/greetings_test.go index 99058cc..1121233 100644 --- a/examples/greetings/examples_test.go +++ b/examples/greetings/greetings_test.go @@ -2,13 +2,12 @@ package greetings_test import ( "context" - "encoding/json" - "fmt" "testing" "time" "github.com/verifa/horizon/examples/greetings" "github.com/verifa/horizon/pkg/hz" + "github.com/verifa/horizon/pkg/hztest" "github.com/verifa/horizon/pkg/server" ) @@ -65,7 +64,7 @@ func TestGreeting(t *testing.T) { Name: "Pekka", }, Spec: &greetings.GreetingSpec{ - Name: hz.P("Pekka"), + Name: "Pekka", }, } err = greetClient.Apply(ctx, greeting) @@ -76,43 +75,23 @@ func TestGreeting(t *testing.T) { // // Verify that the controller reconciles the object. // - // Create a timeout and a done channel. // Watch until the greeting is ready. - // If the timeout is reached, fail the test. + // If the timeout is reached, the test fails. // - timeout := time.After(time.Second * 5) - done := make(chan struct{}) - watcher, err := hz.StartWatcher( + hztest.WatchWaitUntil( + t, ctx, ts.Conn, - hz.WithWatcherFor(greeting), - hz.WithWatcherFn( - func(event hz.Event) (hz.Result, error) { - var watchGreeting greetings.Greeting - if err := json.Unmarshal(event.Data, &watchGreeting); err != nil { - return hz.Result{}, fmt.Errorf( - "unmarshalling greeting: %w", - err, - ) - } - if watchGreeting.Status == nil { - return hz.Result{}, nil - } - if watchGreeting.Status.Ready == true { - close(done) - } - return hz.Result{}, nil - }, - ), + time.Second*5, + greeting, + func(greeting greetings.Greeting) bool { + if greeting.Status == nil { + return false + } + if greeting.Status.Ready == true { + return true + } + return false + }, ) - if err != nil { - t.Fatal("starting greeting watcher: ", err) - } - defer watcher.Close() - - select { - case <-timeout: - t.Fatal("timed out waiting for greeting") - case <-done: - } } diff --git a/examples/greetings/portal.go b/examples/greetings/portal.go index 6fafb73..c1c9a66 100644 --- a/examples/greetings/portal.go +++ b/examples/greetings/portal.go @@ -97,7 +97,7 @@ func (h *PortalHandler) post(rw http.ResponseWriter, req *http.Request) { Name: reqName, }, Spec: &GreetingSpec{ - Name: &reqName, + Name: reqName, }, } client := hz.NewClient( @@ -133,7 +133,7 @@ func (h *PortalHandler) postGreet(rw http.ResponseWriter, req *http.Request) { Name: reqName, }, Spec: &GreetingSpec{ - Name: &reqName, + Name: reqName, }, } client := hz.NewClient(h.Conn, hz.WithClientSessionFromRequest(req)) diff --git a/examples/greetings/portal.templ b/examples/greetings/portal.templ index 51e70ce..a8962c2 100644 --- a/examples/greetings/portal.templ +++ b/examples/greetings/portal.templ @@ -43,7 +43,7 @@ templ (r *PortalRenderer) greetingsActorForm(name string, status *GreetingStatus if status.Ready {

{ status.Response }

} else { -

{ status.FailureMessage }

+

{ status.Error }

} } if err != nil { diff --git a/examples/greetings/controller.go b/examples/greetings/reconciler.go similarity index 55% rename from examples/greetings/controller.go rename to examples/greetings/reconciler.go index 6c96a73..1de61ed 100644 --- a/examples/greetings/controller.go +++ b/examples/greetings/reconciler.go @@ -2,11 +2,9 @@ package greetings import ( "context" - "encoding/json" "fmt" "github.com/verifa/horizon/pkg/hz" - "golang.org/x/exp/slices" ) type GreetingReconciler struct { @@ -43,10 +41,9 @@ func (r *GreetingReconciler) Reconcile( ) if err != nil { applyGreet.Status = &GreetingStatus{ - Ready: false, - FailureReason: fmt.Sprintf("running hello action: %s", err), - Phase: StatusPhaseFailed, - Response: "", + Ready: false, + Error: fmt.Sprintf("running hello action: %s", err), + Response: "", } if err := r.GreetingClient.Apply(ctx, applyGreet); err != nil { return hz.Result{}, fmt.Errorf("updating greeting: %w", err) @@ -55,10 +52,9 @@ func (r *GreetingReconciler) Reconcile( } applyGreet.Status = &GreetingStatus{ - Ready: true, - FailureReason: "", - Phase: StatusPhaseCompleted, - Response: reply.Status.Response, + Ready: true, + Error: "", + Response: reply.Status.Response, } if err := r.GreetingClient.Apply(ctx, applyGreet); err != nil { return hz.Result{}, fmt.Errorf("updating greeting: %w", err) @@ -66,37 +62,3 @@ func (r *GreetingReconciler) Reconcile( return hz.Result{}, nil } - -var _ (hz.Validator) = (*GreetingValidator)(nil) - -type GreetingValidator struct{} - -// Validate implements hz.Validator. -func (*GreetingValidator) Validate(ctx context.Context, data []byte) error { - var greeting Greeting - if err := json.Unmarshal(data, &greeting); err != nil { - return fmt.Errorf("unmarshalling greeting: %w", err) - } - if greeting.Spec == nil { - return fmt.Errorf("spec is required") - } - if greeting.Spec.Name == nil { - return fmt.Errorf("name is required") - } - - if !isFriend(*greeting.Spec.Name) { - return fmt.Errorf( - "we don't greet strangers in Finland, we only know: %v", - friends, - ) - } - return nil -} - -var friends = []string{ - "Pekka", "Matti", "Jukka", "Kari", "Jari", "Mikko", "Ilkka", -} - -func isFriend(name string) bool { - return slices.Contains(friends, name) -} diff --git a/examples/greetings/validator.go b/examples/greetings/validator.go new file mode 100644 index 0000000..e9b5f3f --- /dev/null +++ b/examples/greetings/validator.go @@ -0,0 +1,44 @@ +package greetings + +import ( + "context" + "encoding/json" + "fmt" + "slices" + + "github.com/verifa/horizon/pkg/hz" +) + +var _ (hz.Validator) = (*GreetingValidator)(nil) + +type GreetingValidator struct{} + +// Validate implements hz.Validator. +func (*GreetingValidator) Validate(ctx context.Context, data []byte) error { + var greeting Greeting + if err := json.Unmarshal(data, &greeting); err != nil { + return fmt.Errorf("unmarshalling greeting: %w", err) + } + if greeting.Spec == nil { + return fmt.Errorf("spec is required") + } + if greeting.Spec.Name == "" { + return fmt.Errorf("name is required") + } + + if !isFriend(greeting.Spec.Name) { + return fmt.Errorf( + "we don't greet strangers in Finland, we only know: %v", + friends, + ) + } + return nil +} + +var friends = []string{ + "Pekka", "Matti", "Jukka", "Kari", "Jari", "Mikko", "Ilkka", +} + +func isFriend(name string) bool { + return slices.Contains(friends, name) +} diff --git a/pkg/hz/object.go b/pkg/hz/object.go index 69923ed..cac9aa3 100644 --- a/pkg/hz/object.go +++ b/pkg/hz/object.go @@ -10,6 +10,7 @@ import ( "github.com/verifa/horizon/pkg/internal/managedfields" ) +// Objecter is an interface that represents an object in the Horizon API. type Objecter interface { ObjectKeyer ObjectRevision() *uint64 diff --git a/pkg/hz/portal.go b/pkg/hz/portal.go index ccc08da..b667512 100644 --- a/pkg/hz/portal.go +++ b/pkg/hz/portal.go @@ -84,7 +84,7 @@ func (e *PortalHandler) Start(ctx context.Context) error { client := NewClient( e.conn, WithClientInternal(true), - WithClientManager("TODO"), + WithClientDefaultManager(), ) extClient := ObjectClient[Portal]{Client: client} // TODO: field manager. diff --git a/pkg/server/server.go b/pkg/server/server.go index 8cc11ee..f414c1d 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" + "github.com/nats-io/jwt/v2" "github.com/nats-io/nats.go" "github.com/verifa/horizon/pkg/auth" "github.com/verifa/horizon/pkg/broker" @@ -19,6 +20,8 @@ import ( func WithDevMode() ServerOption { return func(o *serverOptions) { + o.devMode = true + o.runNATSServer = true o.runAuth = true o.runBroker = true @@ -123,6 +126,8 @@ type ServerOption func(*serverOptions) type serverOptions struct { conn *nats.Conn + devMode bool + runNATSServer bool runAuth bool runBroker bool @@ -346,6 +351,37 @@ func (s *Server) Start(ctx context.Context, opts ...ServerOption) error { if err := s.checkRootAccountObject(ctx, s.NS.Auth); err != nil { return fmt.Errorf("checking root account object: %w", err) } + + if opt.devMode { + userConfig, err := jwt.FormatUserConfig( + s.NS.Auth.RootUser.JWT, + []byte(s.NS.Auth.RootUser.Seed), + ) + if err != nil { + return fmt.Errorf("formatting user config: %w", err) + } + fmt.Println(` + _ _ +| |__ ___ _ __(_)_______ _ __ +| '_ \ / _ \| '__| |_ / _ \| '_ \ +| | | | (_) | | | |/ / (_) | | | | +|_| |_|\___/|_| |_/___\___/|_| |_| + _ _ + __| |_____ __ _ __ ___ __| |___ +/ _` + "`" + ` / -_) V / | ' \/ _ \/ _` + "`" + ` / -_) +\__,_\___|\_/ |_|_|_\___/\__,_\___| + `) + + fmt.Println("Below is a NATS credential for the root account.") + fmt.Println("Copy it to a file such as \"nats.creds\"") + + fmt.Println("") + fmt.Println("") + fmt.Println(string(userConfig)) + fmt.Println("") + fmt.Println("") + } + return nil } @@ -389,29 +425,6 @@ func (s *Server) Close() error { return errs } -func (s *Server) Services() []string { - services := []string{} - if s.Gateway != nil { - services = append(services, "gateway") - } - if s.Broker != nil { - services = append(services, "broker") - } - if s.Store != nil { - services = append(services, "store") - } - if s.NS != nil { - services = append(services, "nats") - } - if s.CtlrAccounts != nil { - services = append(services, "ctlr-accounts") - } - if s.ActorUsers != nil { - services = append(services, "actor-users") - } - return services -} - func (s *Server) checkRootAccountObject( ctx context.Context, jwtAuth natsutil.ServerJWTAuth,