Skip to content
Merged
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
20 changes: 10 additions & 10 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ go test -run TestName -v ./internal/job/... # Run a single test

## Architecture (Quick Reference)

- **`cmd/`** - Cobra CLI commands (`client`, `node agent`, `api server`, `nats server`)
- **`internal/api/`** - Echo REST API by domain (`node/`, `job/`, `health/`, `audit/`, `common/`). Types are OpenAPI-generated (`*.gen.go`). Combined OpenAPI spec: `internal/api/gen/api.yaml`
- **`cmd/`** - Cobra CLI commands (`client`, `node agent`, `controller.api`, `nats server`)
- **`internal/controller/api/`** - Echo REST API by domain (`node/`, `job/`, `health/`, `audit/`, `common/`). Types are OpenAPI-generated (`*.gen.go`). Combined OpenAPI spec: `internal/controller/api/gen/api.yaml`
- **`internal/job/`** - Job domain types, subject routing. `client/` for high-level ops
- **`internal/agent/`** - Node agent: consumer/handler/processor pipeline for job execution
- **`internal/provider/`** - Operation implementations: `node/{host,disk,mem,load}`, `network/{dns,ping}`, `process/` (process metrics)
Expand All @@ -50,7 +50,7 @@ domain as a reference. Read the existing files before creating new ones.

### Step 1: OpenAPI Spec + Code Generation

Create `internal/api/{domain}/gen/` with three hand-written files:
Create `internal/controller/api/{domain}/gen/` with three hand-written files:

- `api.yaml` — OpenAPI spec with paths, schemas, and `BearerAuth` security
- `cfg.yaml` — oapi-codegen config (`strict-server: true`, import-mapping
Expand Down Expand Up @@ -133,7 +133,7 @@ input must be validated, and the spec must declare how:

### Step 2: Handler Implementation

Create `internal/api/{domain}/`:
Create `internal/controller/api/{domain}/`:

- `types.go` — domain struct, dependency interfaces (e.g., `Checker`)
- `{domain}.go` — `New()` factory, compile-time interface check:
Expand All @@ -150,10 +150,10 @@ Create `internal/api/{domain}/`:
wrong permissions (403), valid token (200). Uses `api.New()` +
`server.GetXxxHandler()` + `server.RegisterHandlers()` to wire
through `scopeMiddleware`.
See existing examples in `internal/api/job/` and
`internal/api/audit/`.
See existing examples in `internal/controller/api/job/` and
`internal/controller/api/audit/`.

### Step 3: Server Wiring (4 files in `internal/api/`)
### Step 3: Server Wiring (4 files in `internal/controller/api/`)

- `handler_{domain}.go` — `Get{Domain}Handler()` method that wraps the
handler with `NewStrictHandler` + `scopeMiddleware`. Define
Expand All @@ -167,21 +167,21 @@ Create `internal/api/{domain}/`:

### Step 4: Startup Wiring

- `cmd/api_server_start.go` — initialize the handler with real
- `cmd/controller_start.go` — initialize the handler with real
dependencies and pass `api.With{Domain}Handler(h)` to `api.New()`

### Step 5: Update SDK

The SDK client library lives in `pkg/sdk/client/`. Its generated HTTP client
uses the same combined OpenAPI spec as the server
(`internal/api/gen/api.yaml`). Follow the rules in
(`internal/controller/api/gen/api.yaml`). Follow the rules in
@docs/docs/sidebar/sdk/guidelines.md — especially: never expose `gen`
types in public method signatures, add JSON tags to all result types,
and wrap errors with context.

**When modifying existing API specs:**

1. Make changes to `internal/api/{domain}/gen/api.yaml` in this repo
1. Make changes to `internal/controller/api/{domain}/gen/api.yaml` in this repo
2. Run `just generate` to regenerate server code (this also regenerates the
combined spec via `redocly join`)
3. Run `go generate ./pkg/sdk/client/gen/...` to regenerate the SDK client
Expand Down
36 changes: 0 additions & 36 deletions cmd/api.go

This file was deleted.

8 changes: 4 additions & 4 deletions cmd/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,12 @@ var clientCmd = &cobra.Command{
"client configuration",
slog.String("config_file", viper.ConfigFileUsed()),
slog.Bool("debug", appConfig.Debug),
slog.String("api.client.url", appConfig.API.URL),
slog.String("controller.client.url", appConfig.Controller.Client.URL),
)

sdkClient = client.New(
appConfig.API.URL,
appConfig.API.Client.Security.BearerToken,
appConfig.Controller.Client.URL,
appConfig.Controller.Client.Security.BearerToken,
client.WithLogger(logger),
)
},
Expand All @@ -82,5 +82,5 @@ func init() {
clientCmd.PersistentFlags().
StringP("target", "T", "_any", "Target: _any, _all, hostname, or label (group:web.dev)")

_ = viper.BindPFlag("api.client.url", clientCmd.PersistentFlags().Lookup("url"))
_ = viper.BindPFlag("controller.client.url", clientCmd.PersistentFlags().Lookup("url"))
}
10 changes: 5 additions & 5 deletions cmd/client_api.go → cmd/client_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,12 @@ import (
"github.com/spf13/cobra"
)

// clientAPICmd represents the api parent command.
var clientAPICmd = &cobra.Command{
Use: "api",
Short: "API server commands",
// clientControllerCmd represents the controller parent command.
var clientControllerCmd = &cobra.Command{
Use: "controller",
Short: "Controller commands",
}

func init() {
clientCmd.AddCommand(clientAPICmd)
clientCmd.AddCommand(clientControllerCmd)
}
12 changes: 6 additions & 6 deletions cmd/client_api_status.go → cmd/client_controller_status.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,11 @@ import (
"github.com/retr0h/osapi/internal/cli"
)

// clientAPIStatusCmd shows health status for the API server.
var clientAPIStatusCmd = &cobra.Command{
// clientControllerStatusCmd shows health status for the controller.
var clientControllerStatusCmd = &cobra.Command{
Use: "status",
Short: "API server component health",
Long: `Show health status for all registered API servers.`,
Short: "Controller component health",
Long: `Show health status for all registered controllers.`,
Run: func(cmd *cobra.Command, _ []string) {
ctx := cmd.Context()

Expand All @@ -48,10 +48,10 @@ var clientAPIStatusCmd = &cobra.Command{
}

fmt.Println()
displayComponentTable(resp.Data.Registry, "api")
displayComponentTable(resp.Data.Registry, "controller")
},
}

func init() {
clientAPICmd.AddCommand(clientAPIStatusCmd)
clientControllerCmd.AddCommand(clientControllerStatusCmd)
}
38 changes: 20 additions & 18 deletions cmd/api_server.go → cmd/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,12 @@ import (
"github.com/retr0h/osapi/internal/config"
)

// apiServerCmd represents the apiServer command.
var apiServerCmd = &cobra.Command{
Use: "server",
Short: "The server subcommand",
// controllerCmd represents the controller command.
var controllerCmd = &cobra.Command{
Use: "controller",
Short: "Manage the controller process",
Long: `Manage the control plane process. The controller runs the REST API,
component heartbeat, and condition notification watcher.`,
PersistentPreRun: func(_ *cobra.Command, _ []string) {
cli.ValidateDistribution(logger)

Expand All @@ -51,32 +53,32 @@ var apiServerCmd = &cobra.Command{
}

logger.Debug(
"api server configuration",
"controller configuration",
slog.String("config_file", viper.ConfigFileUsed()),
slog.Bool("debug", appConfig.Debug),
slog.Int("api.server.port", appConfig.API.Port),
slog.String("api.server.nats.host", appConfig.API.NATS.Host),
slog.Int("api.server.nats.port", appConfig.API.NATS.Port),
slog.String("api.server.nats.client_name", appConfig.API.NATS.ClientName),
slog.String("api.server.nats.namespace", appConfig.API.NATS.Namespace),
slog.String("api.server.nats.auth.type", appConfig.API.NATS.Auth.Type),
slog.Int("controller.api.port", appConfig.Controller.API.Port),
slog.String("controller.nats.host", appConfig.Controller.NATS.Host),
slog.Int("controller.nats.port", appConfig.Controller.NATS.Port),
slog.String("controller.nats.client_name", appConfig.Controller.NATS.ClientName),
slog.String("controller.nats.namespace", appConfig.Controller.NATS.Namespace),
slog.String("controller.nats.auth.type", appConfig.Controller.NATS.Auth.Type),
slog.String(
"api.server.security.cors.allow_origins",
strings.Join(appConfig.API.Server.Security.CORS.AllowOrigins, ","),
"controller.api.security.cors.allow_origins",
strings.Join(appConfig.Controller.API.Security.CORS.AllowOrigins, ","),
),
slog.String(
"api.server.security.signing_key",
maskedAppConfig.API.Server.Security.SigningKey,
"controller.api.security.signing_key",
maskedAppConfig.Controller.API.Security.SigningKey,
),
)
},
}

func init() {
apiCmd.AddCommand(apiServerCmd)
rootCmd.AddCommand(controllerCmd)

apiServerCmd.PersistentFlags().
controllerCmd.PersistentFlags().
IntP("port", "p", 8080, "Port the server will bind to")

_ = viper.BindPFlag("api.server.port", apiServerCmd.PersistentFlags().Lookup("port"))
_ = viper.BindPFlag("controller.api.port", controllerCmd.PersistentFlags().Lookup("port"))
}
31 changes: 16 additions & 15 deletions cmd/api_server_setup.go → cmd/controller_setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,17 @@ import (
"github.com/nats-io/nats.go/jetstream"
natsclient "github.com/osapi-io/nats-client/pkg/client"

"github.com/retr0h/osapi/internal/api"
"github.com/retr0h/osapi/internal/api/file"
"github.com/retr0h/osapi/internal/api/health"
"github.com/retr0h/osapi/internal/audit"
"github.com/retr0h/osapi/internal/cli"
"github.com/retr0h/osapi/internal/config"
"github.com/retr0h/osapi/internal/controller"
"github.com/retr0h/osapi/internal/controller/api"
"github.com/retr0h/osapi/internal/controller/api/file"
"github.com/retr0h/osapi/internal/controller/api/health"
"github.com/retr0h/osapi/internal/controller/notify"
"github.com/retr0h/osapi/internal/job"
jobclient "github.com/retr0h/osapi/internal/job/client"
"github.com/retr0h/osapi/internal/messaging"
"github.com/retr0h/osapi/internal/notify"
"github.com/retr0h/osapi/internal/provider/process"
"github.com/retr0h/osapi/internal/validation"
)
Expand Down Expand Up @@ -88,10 +89,10 @@ type natsBundle struct {
objStore file.ObjectStoreManager
}

// setupAPIServer connects to NATS, creates the API server with all handlers,
// setupController connects to NATS, creates the API server with all handlers,
// and returns the server manager and NATS bundle. It is used by the standalone
// API server start and combined start commands.
func setupAPIServer(
func setupController(
ctx context.Context,
log *slog.Logger,
connCfg config.NATSConnection,
Expand Down Expand Up @@ -135,12 +136,12 @@ func setupAPIServer(
)

sm := api.New(appConfig, log, serverOpts...)
registerAPIHandlers(
registerControllerHandlers(
sm, b.jobClient, checker, metricsProvider,
metricsHandler, metricsPath, auditStore, b.objStore,
)

startAPIHeartbeat(ctx, log, b.registryKV)
startControllerHeartbeat(ctx, log, b.registryKV)
startConditionWatcher(ctx, log, b.registryKV)

return sm, b
Expand Down Expand Up @@ -505,7 +506,7 @@ func newMetricsProvider(
entry.CPUPercent = reg.Process.CPUPercent
entry.MemBytes = reg.Process.RSSBytes
}
case "api", "nats":
case "controller", "nats":
var reg job.ComponentRegistration
if err := json.Unmarshal(kvEntry.Value(), &reg); err != nil {
continue
Expand Down Expand Up @@ -565,7 +566,7 @@ func createAuditStore(
return store, []api.Option{api.WithAuditStore(store)}
}

func registerAPIHandlers(
func registerControllerHandlers(
sm ServerManager,
jc jobclient.JobClient,
checker health.Checker,
Expand Down Expand Up @@ -639,29 +640,29 @@ func startConditionWatcher(
}()
}

// startAPIHeartbeat resolves the local hostname, creates a ComponentHeartbeat
// startControllerHeartbeat resolves the local hostname, creates a ComponentHeartbeat
// for the API server, and starts it in a background goroutine. The heartbeat
// deregisters when ctx is cancelled.
func startAPIHeartbeat(
func startControllerHeartbeat(
ctx context.Context,
log *slog.Logger,
registryKV jetstream.KeyValue,
) {
hostname, err := os.Hostname()
if err != nil {
log.Warn(
"failed to resolve hostname for API heartbeat, using 'unknown'",
"failed to resolve hostname for controller heartbeat, using 'unknown'",
slog.String("error", err.Error()),
)
hostname = "unknown"
}

hb := api.NewComponentHeartbeat(
hb := controller.NewComponentHeartbeat(
log,
registryKV,
hostname,
"0.1.0",
"api",
"controller",
process.New(),
10*time.Second,
process.ConditionThresholds{
Expand Down
23 changes: 13 additions & 10 deletions cmd/api_server_start.go → cmd/controller_start.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,19 @@ import (
"github.com/retr0h/osapi/internal/telemetry"
)

// apiServerStartCmd represents the apiServerStart command.
var apiServerStartCmd = &cobra.Command{
// controllerStartCmd represents the controller start command.
var controllerStartCmd = &cobra.Command{
Use: "start",
Short: "Start the server",
Long: `Start the API server.
`,
Short: "Start the controller",
Long: `Start the control plane process (API server, heartbeat, notifications).`,
Run: func(cmd *cobra.Command, _ []string) {
ctx := cmd.Context()

shutdownTracer, err := telemetry.InitTracer(ctx, "osapi-api", appConfig.Telemetry.Tracing)
shutdownTracer, err := telemetry.InitTracer(
ctx,
"osapi-controller",
appConfig.Telemetry.Tracing,
)
if err != nil {
cli.LogFatal(logger, "failed to initialize tracer", err)
}
Expand All @@ -51,10 +54,10 @@ var apiServerStartCmd = &cobra.Command{
cli.LogFatal(logger, "failed to initialize meter", err)
}

job.Init(appConfig.API.NATS.Namespace)
job.Init(appConfig.Controller.NATS.Namespace)

log := logger.With("component", "api")
sm, b := setupAPIServer(ctx, log, appConfig.API.NATS, metricsHandler, metricsPath)
log := logger.With("component", "controller")
sm, b := setupController(ctx, log, appConfig.Controller.NATS, metricsHandler, metricsPath)

sm.Start()
cli.RunServer(ctx, sm, func() {
Expand All @@ -66,5 +69,5 @@ var apiServerStartCmd = &cobra.Command{
}

func init() {
apiServerCmd.AddCommand(apiServerStartCmd)
controllerCmd.AddCommand(controllerStartCmd)
}
Loading
Loading