Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ Run `make help` for the complete target list.

```
cmd/hyperfleet-api/ # Entry point + subcommands (serve, migrate)
container/ # Lazily constructed dependencies (DAOs, services, validator, JWT)
servecmd/ # serve command; api_server.go is the composition root
server/ # HTTP servers, router, middleware, entity route registration
environments/ # Environment configs (development, unit_testing, etc.)
pkg/
api/openapi/ # GENERATED — models + embedded spec (never edit)
Expand All @@ -92,10 +95,6 @@ pkg/
errors/ # ServiceError type, RFC 9457 Problem Details
logger/ # Structured logging (slog-based)
config/ # Configuration management
plugins/ # Plugin registration (init-based)
entities/plugin.go # Config-driven entity route registration
resources/plugin.go # Resource routes
generic/plugin.go
openapi/
README.md # Schema import, code generation, and validation details
openapi.yaml # Not in git — generated by make generate
Expand Down Expand Up @@ -141,9 +140,13 @@ Interface + `sql*Service` struct. Constructor injection of DAOs. Return `*errors

Interface + `sql*Dao` struct. Get session via `sessionFactory.New(ctx)`. Call `db.MarkForRollback(ctx, err)` on write errors. Return stdlib `error`.

### Plugins
### Entity Routes

Entity types are config-driven — declared in `config.yaml` under `entities:` and auto-registered at startup. See `plugins/entities/plugin.go`.
Entity types are config-driven — declared in `config.yaml` under `entities:` and auto-registered at startup. See `cmd/hyperfleet-api/server/routes_entities.go`.

### Dependency Injection

`cmd/hyperfleet-api/container` holds dependencies only (DAOs, services, schema validator, JWT handler), lazily constructed and cached. Composition — middleware chains, registrars, router, server — lives in `cmd/hyperfleet-api/servecmd/api_server.go` (`BuildAPIServer`), shared with `test/helper.go` so tests exercise production wiring. Do not add `*config.ApplicationConfig` to `cmd/hyperfleet-api/server`; it takes the narrow `cfg` interface instead.

## Git Workflow

Expand Down
30 changes: 25 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ HyperFleet API is a **stateless REST API** serving as the pure CRUD data layer f
- **Language**: Go 1.26+ with FIPS crypto (`CGO_ENABLED=1 GOEXPERIMENT=boringcrypto`)
- **Database**: PostgreSQL 14.2 with GORM ORM
- **API Spec**: TypeSpec → `hyperfleet-api-spec` Go module → oapi-codegen → Go models
- **Architecture**: Plugin-based route registration, transaction-per-request middleware
- **Architecture**: Container-based dependency injection, config-driven route registration, transaction-per-request middleware

## Critical First Steps

Expand Down Expand Up @@ -82,10 +82,29 @@ Interface + `sql*Dao` implementation using SessionFactory:
- Get session: `db.New(ctx)` — extracts transaction from request context
- On write errors: call `db.MarkForRollback(ctx, err)`

### Plugin Registration
### Entity Route Registration
All entity types (Cluster, NodePool, Channel, Version, WifConfig) are config-driven — declared in `config.yaml` under `entities:`,
registered at startup via `registry.LoadDescriptors()`, routes auto-generated by `plugins/entities/plugin.go`.
No per-entity Go code needed. See `plugins/CLAUDE.md` for details.
registered at startup via `registry.LoadDescriptors()`, routes auto-generated by
`cmd/hyperfleet-api/server/routes_entities.go` (`RegisterEntityRoutes`). No per-entity Go code needed.

Registrars are passed to `server.NewRouter` as `[]server.RouteRegistrar`, so adding a new route group means
constructing a registrar in the composition root — not adding an `init()` hook.

### Dependency Injection
`cmd/hyperfleet-api/container` lazily constructs and caches DAOs, services, the schema validator, and the JWT
handler. It holds **dependencies only** — it does not assemble the server.
- Split by category: `container.go` (struct, constructor, `Close`), `daos.go`, `services.go`, `auth.go`, `validation.go`
- Constructed with explicit inputs: `NewContainer(cfg *config.ApplicationConfig, sessionFactory db.SessionFactory)`
- Not safe for concurrent initialization — startup is sequential
- `Close()` stops the JWT handler's JWKS refresh goroutine

Composition (middleware chains, registrars, router, server) lives in `cmd/hyperfleet-api/servecmd/api_server.go`
(`BuildAPIServer`). It is exported because `test/helper.go` builds the API server through the same path, so
integration tests exercise production wiring.

`cmd/hyperfleet-api/server` deliberately does **not** import `pkg/config` — `APIServer` takes the narrow `cfg`
interface in `api_server.go` instead. Keep it that way; put anything needing `*config.ApplicationConfig` in the
composition root.

### Test Patterns
- Gomega assertions with `RegisterTestingT(t)`
Expand All @@ -103,6 +122,8 @@ No per-entity Go code needed. See `plugins/CLAUDE.md` for details.
- OpenAPI spec and code generation: see [openapi/README.md](openapi/README.md) — run `make generate` before building; generated files in `pkg/api/openapi/` are **never edited**
- Status aggregation: Service layer synthesizes `Available`, `Reconciled`, and `LastKnownReconciled` conditions from adapter reports
- All entity types (Cluster, NodePool, Channel, etc.) are config-driven (`config.yaml` → `registry.LoadDescriptors` → auto-generated routes)
- **Startup wiring**: `servecmd.runServe` loads config → `container.NewContainer(cfg, sessionFactory)` → `BuildAPIServer(...)` → `server.NewRouter` + `server.NewAPIServer`
- Public routes (`/openapi`, `/openapi.html`, metadata) bypass auth, schema validation, and transaction middleware; everything else is gated by both, auth outermost

## Boundaries

Expand All @@ -122,7 +143,6 @@ Subdirectories contain context-specific guidance that loads when you work in tho
- `pkg/dao/CLAUDE.md` — DAO interface, session access, and rollback patterns
- `pkg/db/CLAUDE.md` — SessionFactory and transaction middleware
- `pkg/errors/CLAUDE.md` — Error constructors, codes, and RFC 9457 details
- `plugins/CLAUDE.md` — Plugin registration (config-driven entities)
- `test/CLAUDE.md` — Test conventions, factories, and environment variables
- `charts/CLAUDE.md` — Helm chart testing and configuration
- `openapi/README.md` — OpenAPI schema import, code generation, schema validation, and oapi-codegen config
8 changes: 4 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,10 @@ Overview of key directories and their purpose.
```
hyperfleet-api/
├── cmd/hyperfleet-api/ # Application entry point and CLI commands
│ └── main.go # Server start, migrate, version commands
│ ├── main.go # Server start, migrate, version commands
│ ├── container/ # Dependency container (DAOs, services, validator, JWT handler)
│ ├── servecmd/ # serve command; api_server.go composes the API server
│ └── server/ # HTTP servers, router, middleware, entity routes
├── pkg/
│ ├── api/ # Generated OpenAPI types (DO NOT EDIT)
│ │ └── openapi/ # Generated from openapi/openapi.yaml
Expand All @@ -66,9 +69,6 @@ hyperfleet-api/
│ ├── logger/ # Structured logging (slog-based)
│ ├── presenters/ # Response presenters (DAO models → API responses)
│ └── services/ # Business logic layer (status aggregation, validation)
├── plugins/ # Plugin-based route registration (config-driven entities)
│ ├── entities/ # Entity route auto-generation from config
│ └── resources/ # Resource route registration
├── openapi/ # API specification source
│ ├── openapi.yaml # Source spec (TypeSpec output, has $ref)
│ └── oapi-codegen.yaml # Code generation configuration
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ Groups of compute nodes within clusters.

### Generic Resources

The API also supports generic resource types registered via the plugin system. Currently available:
The API also supports generic resource types declared in `config.yaml` under `entities:`. Currently available:

- **WifConfigs**`GET/POST /api/hyperfleet/v1/wifconfigs`, `GET/PATCH/DELETE .../wifconfigs/{id}`
- **Channels**`GET/POST /api/hyperfleet/v1/channels`, `GET/PATCH/DELETE .../channels/{id}`
Expand Down
23 changes: 23 additions & 0 deletions cmd/hyperfleet-api/container/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package container

import (
"context"

"github.com/openshift-hyperfleet/hyperfleet-api/pkg/auth"
)

func (c *Container) JWTHandler() *auth.JWTHandler {
if c.jwtHandler == nil {
jwtHandler, err := auth.NewJWTHandler(
context.Background(),
auth.JWTHandlerConfig{
Issuers: c.cfg.Server.JWT.Configs,
},
)
if err != nil {
panic("unable to create JWT handler: " + err.Error())
}
c.jwtHandler = jwtHandler
}
return c.jwtHandler
}
52 changes: 52 additions & 0 deletions cmd/hyperfleet-api/container/container.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package container

import (
"github.com/openshift-hyperfleet/hyperfleet-api/pkg/auth"
"github.com/openshift-hyperfleet/hyperfleet-api/pkg/config"
"github.com/openshift-hyperfleet/hyperfleet-api/pkg/dao"
"github.com/openshift-hyperfleet/hyperfleet-api/pkg/db"
"github.com/openshift-hyperfleet/hyperfleet-api/pkg/services"
"github.com/openshift-hyperfleet/hyperfleet-api/pkg/validators"
)

// Container lazily constructs and caches application dependencies during
// sequential startup. It is not safe for concurrent initialization.
//
// Container owns dependencies only. Assembling them into a running API server
// is the composition root's job — see buildAPIServer in the servecmd package.
//
// TODO(HYPERFLEET-1371): Once the environments/ package is removed,
// Container should source SessionFactory directly (e.g. from config/Viper)
// rather than accepting it as a constructor parameter. Close() should also
// close the SessionFactory at that point.
type Container struct {
cfg *config.ApplicationConfig
sessionFactory db.SessionFactory

resourceDao dao.ResourceDao
resourceLabelDao dao.ResourceLabelDao
adapterStatusDao dao.AdapterStatusDao
resourceConditionDao dao.ResourceConditionDao
genericDao dao.GenericDao

resourceService services.ResourceService
adapterStatusService services.AdapterStatusService
genericService services.GenericService

schemaValidator *validators.SchemaValidator
jwtHandler *auth.JWTHandler
}

func NewContainer(cfg *config.ApplicationConfig, sessionFactory db.SessionFactory) *Container {
return &Container{cfg: cfg, sessionFactory: sessionFactory}
}

func (c *Container) SessionFactory() db.SessionFactory {
return c.sessionFactory
}

func (c *Container) Close() {
if c.jwtHandler != nil {
c.jwtHandler.Close()
}
}
62 changes: 62 additions & 0 deletions cmd/hyperfleet-api/container/container_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package container

import (
"testing"

. "github.com/onsi/gomega"

"github.com/openshift-hyperfleet/hyperfleet-api/pkg/config"
dbmocks "github.com/openshift-hyperfleet/hyperfleet-api/pkg/db/mocks"
)

func newTestContainer(t *testing.T) *Container {
t.Helper()

sessionFactory := dbmocks.NewMockSessionFactory()
t.Cleanup(func() { _ = sessionFactory.Close() })

return NewContainer(config.NewApplicationConfig(), sessionFactory)
}

func TestContainerCachesDAOsAndServices(t *testing.T) {
RegisterTestingT(t)

c := newTestContainer(t)

Expect(c.SessionFactory()).NotTo(BeNil())

Expect(c.ResourceDao()).NotTo(BeNil())
Expect(c.ResourceDao()).To(BeIdenticalTo(c.ResourceDao()))
Expect(c.ResourceLabelDao()).NotTo(BeNil())
Expect(c.ResourceLabelDao()).To(BeIdenticalTo(c.ResourceLabelDao()))
Expect(c.AdapterStatusDao()).NotTo(BeNil())
Expect(c.AdapterStatusDao()).To(BeIdenticalTo(c.AdapterStatusDao()))
Expect(c.ResourceConditionDao()).NotTo(BeNil())
Expect(c.ResourceConditionDao()).To(BeIdenticalTo(c.ResourceConditionDao()))
Expect(c.GenericDao()).NotTo(BeNil())
Expect(c.GenericDao()).To(BeIdenticalTo(c.GenericDao()))

Expect(c.GenericService()).NotTo(BeNil())
Expect(c.GenericService()).To(BeIdenticalTo(c.GenericService()))
Expect(c.AdapterStatusService()).NotTo(BeNil())
Expect(c.AdapterStatusService()).To(BeIdenticalTo(c.AdapterStatusService()))
Expect(c.ResourceService()).NotTo(BeNil())
Expect(c.ResourceService()).To(BeIdenticalTo(c.ResourceService()))
}

func TestContainerConstructionIsLazy(t *testing.T) {
RegisterTestingT(t)

c := newTestContainer(t)

Expect(c.resourceDao).To(BeNil())
Expect(c.resourceLabelDao).To(BeNil())
Expect(c.adapterStatusDao).To(BeNil())
Expect(c.resourceConditionDao).To(BeNil())
Expect(c.genericDao).To(BeNil())
Expect(c.resourceService).To(BeNil())
Expect(c.adapterStatusService).To(BeNil())
Expect(c.genericService).To(BeNil())
Expect(c.schemaValidator).To(BeNil())
Expect(c.jwtHandler).To(BeNil())
}
40 changes: 40 additions & 0 deletions cmd/hyperfleet-api/container/daos.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package container

import (
"github.com/openshift-hyperfleet/hyperfleet-api/pkg/dao"
)

func (c *Container) ResourceDao() dao.ResourceDao {
if c.resourceDao == nil {
c.resourceDao = dao.NewResourceDao(c.sessionFactory)
}
return c.resourceDao
}

func (c *Container) ResourceLabelDao() dao.ResourceLabelDao {
if c.resourceLabelDao == nil {
c.resourceLabelDao = dao.NewResourceLabelDao(c.sessionFactory)
}
return c.resourceLabelDao
}

func (c *Container) AdapterStatusDao() dao.AdapterStatusDao {
if c.adapterStatusDao == nil {
c.adapterStatusDao = dao.NewAdapterStatusDao(c.sessionFactory)
}
return c.adapterStatusDao
}

func (c *Container) ResourceConditionDao() dao.ResourceConditionDao {
if c.resourceConditionDao == nil {
c.resourceConditionDao = dao.NewResourceConditionDao(c.sessionFactory)
}
return c.resourceConditionDao
}

func (c *Container) GenericDao() dao.GenericDao {
if c.genericDao == nil {
c.genericDao = dao.NewGenericDao(c.sessionFactory)
}
return c.genericDao
}
32 changes: 32 additions & 0 deletions cmd/hyperfleet-api/container/services.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package container

import (
"github.com/openshift-hyperfleet/hyperfleet-api/pkg/services"
)

func (c *Container) ResourceService() services.ResourceService {
if c.resourceService == nil {
c.resourceService = services.NewResourceService(
c.ResourceDao(),
c.ResourceLabelDao(),
c.AdapterStatusDao(),
c.ResourceConditionDao(),
c.GenericService(),
)
}
return c.resourceService
}

func (c *Container) AdapterStatusService() services.AdapterStatusService {
if c.adapterStatusService == nil {
c.adapterStatusService = services.NewAdapterStatusService(c.AdapterStatusDao())
}
return c.adapterStatusService
}

func (c *Container) GenericService() services.GenericService {
if c.genericService == nil {
c.genericService = services.NewGenericService(c.GenericDao())
}
return c.genericService
}
21 changes: 21 additions & 0 deletions cmd/hyperfleet-api/container/validation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package container

import (
"context"

"github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger"
"github.com/openshift-hyperfleet/hyperfleet-api/pkg/validators"
)

func (c *Container) SchemaValidator() *validators.SchemaValidator {
if c.schemaValidator == nil {
schemaPath := c.cfg.Server.OpenAPISchemaPath
schemaValidator, err := validators.NewSchemaValidator(schemaPath)
if err != nil {
panic("unable to create schema validator: " + err.Error())
}
c.schemaValidator = schemaValidator
logger.With(context.Background(), logger.FieldSchemaPath, schemaPath).Info("Schema validation enabled")
}
return c.schemaValidator
}
6 changes: 0 additions & 6 deletions cmd/hyperfleet-api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,6 @@ import (
"github.com/openshift-hyperfleet/hyperfleet-api/cmd/hyperfleet-api/servecmd"
"github.com/openshift-hyperfleet/hyperfleet-api/pkg/api"
"github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger"

// Import plugins to trigger their init() functions
_ "github.com/openshift-hyperfleet/hyperfleet-api/plugins/adapterStatus"
_ "github.com/openshift-hyperfleet/hyperfleet-api/plugins/entities"
_ "github.com/openshift-hyperfleet/hyperfleet-api/plugins/generic"
_ "github.com/openshift-hyperfleet/hyperfleet-api/plugins/resources"
)

// nolint
Expand Down
Loading