diff --git a/.fabrica.yaml b/.fabrica.yaml new file mode 100644 index 0000000..f8f1f27 --- /dev/null +++ b/.fabrica.yaml @@ -0,0 +1,39 @@ +project: + name: remote-console + module: github.com/OpenCHAMI/remote-console + description: OpenCHAMI remote console service + created: 2026-07-08T18:41:14.860713878Z +features: + validation: + enabled: true + mode: strict + events: + enabled: false + bus_type: memory + conditional: + enabled: true + etag_algorithm: sha256 + auth: + enabled: true + storage: + enabled: false + type: file + db_driver: sqlite3 + reconciliation: + enabled: false + worker_count: 5 + requeue_delay: 5 + security: + authn: + enabled: true + authz: + enabled: false + mode: enforce +generation: + handlers: true + storage: false + client: false + openapi: true + events: false + middleware: true + reconciliation: false diff --git a/.github/workflows/integration_test.yaml b/.github/workflows/integration_test.yaml index c4bff08..0527537 100644 --- a/.github/workflows/integration_test.yaml +++ b/.github/workflows/integration_test.yaml @@ -15,7 +15,7 @@ jobs: go-version-file: go.mod - name: Build remote-console binary for Dockerfile run: | - go build -o remote-console ./cmd/remote-console + go build -o remote-console ./cmd/server - name: Run integration tests run: | go test ./test -timeout 20m ./test diff --git a/.github/workflows/unit_test.yaml b/.github/workflows/unit_test.yaml index f118d78..09905e1 100644 --- a/.github/workflows/unit_test.yaml +++ b/.github/workflows/unit_test.yaml @@ -15,5 +15,5 @@ jobs: go-version-file: go.mod - name: Run unit tests run: | - # Exclude the integration tests - go test ./internal/... + # Exclude Docker-backed integration tests in ./test. + go test ./apis/... ./cmd/... ./internal/... ./pkg/... diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 72e8ac0..7991816 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -8,7 +8,7 @@ before: builds: - id: remote-console - main: ./cmd/remote-console + main: ./cmd/server binary: remote-console goos: - linux diff --git a/Dockerfile b/Dockerfile index 62385c6..991ffbc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -44,14 +44,16 @@ RUN go env -w GO111MODULE=auto # Copy source files COPY cmd $GOPATH/src/github.com/OpenCHAMI/remote-console/v2/cmd +COPY apis $GOPATH/src/github.com/OpenCHAMI/remote-console/v2/apis COPY configs configs COPY scripts scripts COPY internal $GOPATH/src/github.com/OpenCHAMI/remote-console/v2/internal +COPY pkg $GOPATH/src/github.com/OpenCHAMI/remote-console/v2/pkg COPY go.mod $GOPATH/src/github.com/OpenCHAMI/remote-console/v2/go.mod COPY go.sum $GOPATH/src/github.com/OpenCHAMI/remote-console/v2/go.sum # Build the image -RUN set -ex && go build -C $GOPATH/src/github.com/OpenCHAMI/remote-console/v2/cmd/remote-console -v -o /usr/local/bin/remote-console +RUN set -ex && go build -C $GOPATH/src/github.com/OpenCHAMI/remote-console/v2/cmd/server -v -o /usr/local/bin/remote-console ### Final Stage ### FROM ubuntu:24.04 AS final diff --git a/Dockerfile.debug b/Dockerfile.debug index a0926a5..90cc917 100644 --- a/Dockerfile.debug +++ b/Dockerfile.debug @@ -1,4 +1,4 @@ -FROM golang:1.24-bookworm +FROM golang:1.26-bookworm # Configure go env ENV GOPATH=/usr/local/golib @@ -8,14 +8,16 @@ RUN export CGO_ENABLED=0 # Copy source files COPY cmd $GOPATH/src/github.com/OpenCHAMI/remote-console/v2/cmd +COPY apis $GOPATH/src/github.com/OpenCHAMI/remote-console/v2/apis COPY configs configs COPY scripts scripts COPY internal $GOPATH/src/github.com/OpenCHAMI/remote-console/v2/internal +COPY pkg $GOPATH/src/github.com/OpenCHAMI/remote-console/v2/pkg COPY go.mod $GOPATH/src/github.com/OpenCHAMI/remote-console/v2/go.mod COPY go.sum $GOPATH/src/github.com/OpenCHAMI/remote-console/v2/go.sum # Build the image -RUN go build -C $GOPATH/src/github.com/OpenCHAMI/remote-console/v2/cmd/remote-console -v \ +RUN go build -C $GOPATH/src/github.com/OpenCHAMI/remote-console/v2/cmd/server -v \ -gcflags=all="-N -l" \ -o /usr/local/bin/remote-console diff --git a/Makefile b/Makefile index 4ebaa4d..e5eb2be 100644 --- a/Makefile +++ b/Makefile @@ -8,8 +8,10 @@ VERSION ?= $(shell git describe --tags --always --abbrev=0) GOLANGCI_LINT ?= golangci-lint GO_PACKAGES ?= ./... TEST_PACKAGES ?= $(GO_PACKAGES) +FABRICA ?= go run github.com/openchami/fabrica/cmd/fabrica +FABRICA_GENERATE_ARGS ?= -.PHONY: all lint test image +.PHONY: all lint test image generate format-generated all : lint image @@ -22,3 +24,11 @@ test: image: docker build --pull $(DOCKER_ARGS) --tag '$(NAME):$(VERSION)' . + +generate: + $(FABRICA) generate $(FABRICA_GENERATE_ARGS) + $(MAKE) format-generated + +format-generated: + @files=$$(find apis cmd/server pkg/resources -name '*.go' -type f 2>/dev/null); \ + if [ -n "$$files" ]; then gofmt -w $$files; fi diff --git a/apis.yaml b/apis.yaml new file mode 100644 index 0000000..d42bdcf --- /dev/null +++ b/apis.yaml @@ -0,0 +1,10 @@ +groups: + - name: remote-console.openchami.io + storageVersion: v1 + versions: + - v1 + resources: + Console: + path: /remote-console/consoles + operations: + - list \ No newline at end of file diff --git a/apis/remote-console.openchami.io/v1/console_types.go b/apis/remote-console.openchami.io/v1/console_types.go new file mode 100644 index 0000000..878ac3b --- /dev/null +++ b/apis/remote-console.openchami.io/v1/console_types.go @@ -0,0 +1,41 @@ +// Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC +// +// SPDX-License-Identifier: MIT + +package v1 + +import "github.com/openchami/fabrica/pkg/fabrica" + +// Console represents a console resource +type Console struct { + APIVersion string `json:"apiVersion" yaml:"apiVersion"` + Kind string `json:"kind" yaml:"kind"` + Metadata fabrica.Metadata `json:"metadata" yaml:"metadata"` + Spec ConsoleSpec `json:"spec" yaml:"spec" validate:"required"` +} + +// ConsoleSpec defines the desired state of Console +type ConsoleSpec struct { + ConnectionType string `json:"connectionType" yaml:"connectionType"` + ConnectionHost string `json:"connectionHost" yaml:"connectionHost"` + ConnectionPort int `json:"connectionPort" yaml:"connectionPort"` + ConsoleEntryCommand string `json:"consoleEntryCommand,omitempty" yaml:"consoleEntryCommand,omitempty"` +} + +// GetKind returns the kind of the resource +func (r *Console) GetKind() string { + return "Console" +} + +// GetName returns the name of the resource +func (r *Console) GetName() string { + return r.Metadata.Name +} + +// GetUID returns the UID of the resource +func (r *Console) GetUID() string { + return r.Metadata.UID +} + +// IsHub marks this as the hub/storage version +func (r *Console) IsHub() {} diff --git a/authz/grouping.csv b/authz/grouping.csv new file mode 100644 index 0000000..5af3fd0 --- /dev/null +++ b/authz/grouping.csv @@ -0,0 +1,10 @@ +# SPDX-License-Identifier: MIT +# SPDX-FileCopyrightText: 2025-2026 OpenCHAMI a Series of LF Projects, LLC +# Starter grouping policy for Fabrica-generated services. +# +# TokenSmith's authorizer evaluates role subjects directly (role:). +# Use grouping for role inheritance so broader roles automatically include the +# permissions of narrower roles. + +g, role:editor, role:viewer +g, role:admin, role:editor diff --git a/authz/model.conf b/authz/model.conf new file mode 100644 index 0000000..7d45c1a --- /dev/null +++ b/authz/model.conf @@ -0,0 +1,23 @@ +# SPDX-License-Identifier: MIT +# SPDX-FileCopyrightText: 2025-2026 OpenCHAMI a Series of LF Projects, LLC +# Starter Casbin model for Fabrica-generated services. +# +# This model matches the default AuthZ classifier output: +# sub = normalized role subject (TokenSmith enforces with role:) +# obj = stable chi route pattern (for example /sensors/{uid}) +# act = HTTP method (GET, POST, PUT, PATCH, DELETE) + +[request_definition] +r = sub, obj, act + +[policy_definition] +p = sub, obj, act + +[role_definition] +g = _, _ + +[policy_effect] +e = some(where (p.eft == allow)) + +[matchers] +m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act diff --git a/authz/policy.csv b/authz/policy.csv new file mode 100644 index 0000000..9a37b9b --- /dev/null +++ b/authz/policy.csv @@ -0,0 +1,18 @@ +# SPDX-License-Identifier: MIT +# SPDX-FileCopyrightText: 2025-2026 OpenCHAMI a Series of LF Projects, LLC +# Starter policy for Fabrica-generated services. +# +# TokenSmith evaluates the caller's normalized roles using Casbin subjects in the +# form role:. Start simple, then tailor these tuples for your deployment. + +# Console +p, role:viewer, /remote-console/consoles, GET +p, role:viewer, /remote-console/consoles/{uid}, GET + +p, role:editor, /remote-console/consoles, GET +p, role:editor, /remote-console/consoles/{uid}, GET + +p, role:admin, /remote-console/consoles, GET +p, role:admin, /remote-console/consoles/{uid}, GET + +# Add deployment-specific tuples below. diff --git a/cmd/remote-console/command.go b/cmd/remote-console/command.go deleted file mode 100644 index f785179..0000000 --- a/cmd/remote-console/command.go +++ /dev/null @@ -1,52 +0,0 @@ -// Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC -// -// SPDX-License-Identifier: MIT - -package main - -import ( - "context" - "log" - "strings" - - "github.com/urfave/cli/v3" - "github.com/urfave/sflags" - "github.com/urfave/sflags/gen/gcli" -) - -func ensureTrailingSlash(url string) string { - if url != "" && !strings.HasSuffix(url, "/") { - return url + "/" - } - return url -} - -func command(config *remoteConsoleConfig) *cli.Command { - - cmd := &cli.Command{ - Name: "remote-console", - Usage: "access remote consoles", - Description: "OpenCHAMI remote console service", - Before: func(ctx context.Context, c *cli.Command) (context.Context, error) { - config.SmdURL = ensureTrailingSlash(config.SmdURL) - - return ctx, validateConfig(config) - }, - Action: func(context.Context, *cli.Command) error { - return runService(*config) - }, - } - - err := gcli.ParseToV3(config, &cmd.Flags, sflags.EnvPrefix("RCS_")) - if err != nil { - log.Fatalf("err: %v", err) - } - - // Add log config separately, so we can flatten it - err = gcli.ParseToV3(&config.Log, &cmd.Flags, sflags.EnvPrefix("RCS_")) - if err != nil { - log.Fatalf("err: %v", err) - } - - return cmd -} diff --git a/cmd/remote-console/config.go b/cmd/remote-console/config.go deleted file mode 100644 index 9dbe5b7..0000000 --- a/cmd/remote-console/config.go +++ /dev/null @@ -1,101 +0,0 @@ -// Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC -// -// SPDX-License-Identifier: MIT - -package main - -import ( - "fmt" - "slices" - - "github.com/OpenCHAMI/remote-console/internal/conman" - "github.com/OpenCHAMI/remote-console/internal/creds" - "github.com/OpenCHAMI/remote-console/internal/logs" -) - -type OAuth2Config struct { - ClientID string `desc:"OAuth2 client ID for SMD authentication"` - ClientSecret string `desc:"OAuth2 client secret for SMD authentication"` - TokenURL string `desc:"OAuth2 token endpoint URL for SMD authentication"` - Scopes []string `desc:"OAuth2 scopes for SMD authentication"` -} - -type remoteConsoleConfig struct { - Log logs.LogConfig `flag:"-"` - Conman conman.ConmanConfig - Creds creds.CredsConfig - HttpListen string `desc:"HTTP listen address"` - NewNodeLookup int `desc:"Interval in seconds to look for new nodes"` - CredsMonitorInterval int `desc:"Interval in seconds to monitor credential updates"` - SmdURL string `desc:"URL for the SMD service"` - JwksURL string `desc:"JWKS URL for fetching public keys for JWT validation (optional)"` - JwksFetchInterval int `desc:"Interval in seconds to retry fetching JWKS on failure"` - Oauth2 OAuth2Config -} - -func DefaultConfig() remoteConsoleConfig { - return remoteConsoleConfig{ - Log: logs.DefaultLogConfig(), - Conman: conman.DefaultConmanConfig(), - Creds: creds.DefaultCredsConfig(), - HttpListen: "0.0.0.0:26776", - NewNodeLookup: 120, - CredsMonitorInterval: 30, - SmdURL: "http://cray-smd/", - JwksURL: "", - JwksFetchInterval: 5, - // Note: Oauth2 vs OAuth2 so the sflags generate the correct flag name - Oauth2: OAuth2Config{}, - } -} - -func validateCredsConfig(config *remoteConsoleConfig) error { - credConfig := config.Creds - - if credConfig.SecureStorageAdapter != "" { - _, err := creds.NewStorageAdapter(string(credConfig.SecureStorageAdapter)) - if err != nil { - return fmt.Errorf("invalid secure storage adapter: %s, valid values are (vault or local)", credConfig.SecureStorageAdapter) - } - - if credConfig.SecureStorageAdapter == creds.StorageAdapterLocal { - if credConfig.LocalStoreFilePath == "" { - return fmt.Errorf("a local storage path must be set when using the local secure storage adapter") - } - - if credConfig.LocalStoreKey == "" { - return fmt.Errorf("a local storage key must be set when using the local secure storage adapter") - } - } - } - - return nil -} - -func validateConfig(config *remoteConsoleConfig) error { - if err := validateCredsConfig(config); err != nil { - return err - } - - // Validate OAuth2 configuration - either all or nothing - oauth2 := config.Oauth2 - - oauth2Provided := []bool{ - oauth2.ClientID != "", - oauth2.ClientSecret != "", - oauth2.TokenURL != "", - len(oauth2.Scopes) > 0, - } - - // All - allProvided := !slices.Contains(oauth2Provided, false) - - // Nothing - noneProvided := !slices.Contains(oauth2Provided, true) - - if !allProvided && !noneProvided { - return fmt.Errorf("incomplete OAuth2 configuration: all fields (oauth2-client-id, oauth2-client-secret, oauth2-token-url and oauth2-scopes) must be provided") - } - - return nil -} diff --git a/cmd/remote-console/main.go b/cmd/remote-console/main.go deleted file mode 100644 index 7dadd90..0000000 --- a/cmd/remote-console/main.go +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC -// Copyright © 2021-2023 Hewlett Packard Enterprise Development LP -// -// SPDX-License-Identifier: MIT -// This file handles command line entry and enables the http service. - -package main - -import ( - "context" - "log/slog" - "os" - "strings" -) - -// initLogger sets up logging with slog -func initLogger() { - // Determine log level from environment - level := slog.LevelInfo - if logLevel := os.Getenv("LOG_LEVEL"); logLevel != "" { - if err := level.UnmarshalText([]byte(logLevel)); err != nil { - // If parsing fails, keep default INFO level - slog.Warn("Invalid LOG_LEVEL, using INFO", "value", logLevel, "error", err) - } - } - - // Determine format from environment (json or text) - format := strings.ToLower(os.Getenv("LOG_FORMAT")) - var handler slog.Handler - if format == "json" { - handler = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: level}) - } else { - // Default to text format for development - handler = slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level}) - } - - slog.SetDefault(slog.New(handler)) - slog.Info("Logger initialized", "level", level.String(), "format", format) -} - -func main() { - initLogger() - - config := DefaultConfig() - cli := command(&config) - - if err := cli.Run(context.Background(), os.Args); err != nil { - slog.Error("Service failed", "error", err) - os.Exit(1) - } -} diff --git a/cmd/server/auth_helpers_generated.go b/cmd/server/auth_helpers_generated.go new file mode 100644 index 0000000..436db64 --- /dev/null +++ b/cmd/server/auth_helpers_generated.go @@ -0,0 +1,115 @@ +// Code generated by Fabrica dev. DO NOT EDIT. +// Template: init/auth_helpers.go.tmpl +// Generated: 2026-07-08T18:41:14Z +// +// SPDX-FileCopyrightText: Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC +// +// SPDX-License-Identifier: MIT + +package main + +import ( + "context" + "fmt" + "log" + "net/http" + "os" + "strings" + + tokensmithauthn "github.com/openchami/tokensmith/pkg/authn" + tokensmithauthz "github.com/openchami/tokensmith/pkg/authz" + tokensmithauthzengine "github.com/openchami/tokensmith/pkg/authz/engine" +) + +func initializeAuthMiddleware(config *Config) (func(http.Handler) http.Handler, func(http.Handler) http.Handler, error) { + var authnMiddleware func(http.Handler) http.Handler + var authzMiddleware func(http.Handler) http.Handler + if config.AuthEnabled { + jwksURL := os.Getenv("TOKENSMITH_JWKS_URL") + if jwksURL == "" { + return nil, nil, fmt.Errorf("TOKENSMITH_JWKS_URL is required when security.authn.enabled is true") + } + + mw, err := tokensmithauthn.Middleware(tokensmithauthn.Options{ + JWKSURLs: []string{jwksURL}, + DisableIssuerValidation: true, + DisableAudienceValidation: true, + }) + if err != nil { + return nil, nil, fmt.Errorf("failed to initialize TokenSmith JWT middleware: %w", err) + } + authnMiddleware = mw + log.Println("Authentication enabled (TokenSmith JWT)") + + authzMode, err := parseAuthZMode(os.Getenv("TOKENSMITH_AUTHZ_MODE")) + if err != nil { + return nil, nil, fmt.Errorf("failed to parse TOKENSMITH_AUTHZ_MODE: %w", err) + } + if authzMode != tokensmithauthz.ModeOff { + modelPath := getenvDefault("TOKENSMITH_CASBIN_MODEL", "./authz/model.conf") + policyPath := getenvDefault("TOKENSMITH_CASBIN_POLICY", "./authz/policy.csv") + groupingPath := getenvDefault("TOKENSMITH_CASBIN_GROUPING", "./authz/grouping.csv") + + authorizer, err := tokensmithauthzengine.NewBuilder(). + WithModelPath(modelPath). + WithPolicyPath(policyPath). + WithGroupingPath(groupingPath). + Build() + if err != nil { + return nil, nil, fmt.Errorf("failed to initialize TokenSmith AuthZ engine: %w", err) + } + + authzMiddleware = tokensmithauthz.NewMiddleware( + authorizer, + authzRouteMapper{}, + tokensmithauthz.WithMode(authzMode), + tokensmithauthz.WithRequireAuthn(true), + tokensmithauthz.WithOnDecision(logAuthZDecision), + ).Handler + log.Printf("Authorization enabled (TokenSmith AuthZ, mode=%s)", authzMode) + } else { + log.Println("Authorization disabled") + } + } else { + log.Println("Authentication disabled") + } + + return authnMiddleware, authzMiddleware, nil +} + +func parseAuthZMode(s string) (tokensmithauthz.Mode, error) { + switch strings.TrimSpace(strings.ToLower(s)) { + case "", "off": + return tokensmithauthz.ModeOff, nil + case "shadow": + return tokensmithauthz.ModeShadow, nil + case "enforce": + return tokensmithauthz.ModeEnforce, nil + default: + return tokensmithauthz.ModeOff, fmt.Errorf("unknown authz mode %q", s) + } +} + +func getenvDefault(key, fallback string) string { + if value := strings.TrimSpace(os.Getenv(key)); value != "" { + return value + } + return fallback +} + +func logAuthZDecision(_ context.Context, rec tokensmithauthz.DecisionRecord) { + log.Printf( + "authz decision subject=%q decision=%q reason=%q mode=%q object=%q action=%q method=%q path=%q request_id=%q policy_version=%q roles_count=%d", + rec.PrincipalID, + rec.Decision, + rec.Reason, + rec.Mode, + rec.Object, + rec.Action, + rec.Method, + rec.Path, + rec.RequestID, + rec.PolicyVersion, + rec.RolesCount, + ) +} diff --git a/cmd/server/authz_classifier.go b/cmd/server/authz_classifier.go new file mode 100644 index 0000000..14aa722 --- /dev/null +++ b/cmd/server/authz_classifier.go @@ -0,0 +1,42 @@ +// Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC +// +// SPDX-License-Identifier: MIT +// +// This file contains the user-editable authorization tuple classifier. +// +// ✅ This file is safe to edit: it will NOT be overwritten by regeneration. +// +// The AuthZ middleware calls ClassifyRequestForAuthZ to derive a tuple used for +// policy enforcement and decision logging. +package main + +import "net/http" + +// ClassifyRequestForAuthZ returns (subject, object, action, protected, ok, reason). +// +// Semantics: +// - ok=false indicates the request could not be classified reliably. +// - enforce mode: treated like deny (403), reason logged. +// - shadow mode: decision logged, request allowed. +// - protected indicates whether the request should be AuthZ-protected. +// Public endpoints should already bypass AuthZ structurally, but protected is +// retained for future extension and custom routing. +// +// Default implementation notes: +// - subject: derived from TokenSmith claims in request context (wired by middleware). +// - object: prefers chi's RoutePattern (stable across path params); falls back to r.URL.Path. +// - action: r.Method. +// +// Example tuples (object/action): +// - ("/bmcs", "GET") +// - ("/bmcs/{uid}", "DELETE") +// - ("/bmcs/{uid}/status", "PATCH") +func ClassifyRequestForAuthZ(r *http.Request) (subject, object, action string, protected, ok bool, reason string) { + subject, object, action, protected, ok, reason = DefaultClassifyRequestForAuthZ(r) + if isConsoleWebSocketRequest(r) && isConsoleWebSocketPath(r.URL.Path) { + object = "/remote-console/consoles/{uid}" + reason = "object derived from remote console websocket path" + ok = subject != "" && action != "" + } + return subject, object, action, protected, ok, reason +} diff --git a/cmd/server/authz_classifier_generated.go b/cmd/server/authz_classifier_generated.go new file mode 100644 index 0000000..a99890a --- /dev/null +++ b/cmd/server/authz_classifier_generated.go @@ -0,0 +1,92 @@ +// Code generated by Fabrica. DO NOT EDIT. +// Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC +// +// SPDX-License-Identifier: MIT +// +// This file contains the default authorization tuple classifier used by the +// TokenSmith AuthZ middleware integration. +// +// Generated from: pkg/codegen/templates/server/authz_classifier.go.tmpl +// +// ⚠️ This file will be overwritten by regeneration. +// To customize request classification (subject/object/action/protected), edit: +// +// cmd/server/authz_classifier.go +// +// which is create-once (generated only if missing). +package main + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + tokensmithauthn "github.com/openchami/tokensmith/pkg/authn" + tokensmithauthz "github.com/openchami/tokensmith/pkg/authz" +) + +type authzRouteMapper struct{} + +func (authzRouteMapper) Map(r *http.Request, _ tokensmithauthz.Principal) (tokensmithauthz.RouteDecision, error) { + _, object, action, protected, ok, _ := ClassifyRequestForAuthZ(r) + if !protected || !ok { + return tokensmithauthz.RouteDecision{Mapped: false}, nil + } + return tokensmithauthz.RouteDecision{ + Mapped: true, + Object: object, + Action: action, + }, nil +} + +// DefaultClassifyRequestForAuthZ derives an AuthZ tuple and protection classification. +// +// Return values: +// - subject: string identifier for the caller (usually derived from JWT claims) +// - object: a stable resource identifier (prefer chi RoutePattern) +// - action: usually HTTP method (GET/POST/PUT/PATCH/DELETE) +// - protected: whether this request should be protected by AuthZ (default true) +// - ok: whether a stable tuple could be derived; ok=false is treated as deny in +// enforce mode, and logged+allowed in shadow mode. +// - reason: a short human-readable reason describing tuple derivation +// +// Policy authoring notes: +// - object is typically a chi route pattern like: +// /bmcs +// /bmcs/{uid} +// /bmcs/{uid}/status +// - action is the HTTP method (e.g. GET, POST, PATCH) +func DefaultClassifyRequestForAuthZ(r *http.Request) (subject, object, action string, protected, ok bool, reason string) { + protected = true + + if p, ok := tokensmithauthn.PrincipalFromContext(r.Context()); ok && p.ID != "" { + subject = p.ID + } + action = r.Method + if action == "" { + return subject, "", "", protected, false, "missing HTTP method" + } + + if rc := chi.RouteContext(r.Context()); rc != nil { + if rp := rc.RoutePattern(); rp != "" { + object = rp + reason = "object derived from chi route pattern" + } else { + object = r.URL.Path + reason = "object derived from URL path (chi route pattern unavailable)" + } + } else { + object = r.URL.Path + reason = "object derived from URL path (chi route context unavailable)" + } + + if object == "" { + return subject, "", action, protected, false, "missing object" + } + + // Subject is required for stable policy enforcement. + if subject == "" { + return subject, object, action, protected, false, "missing subject" + } + + return subject, object, action, protected, true, reason +} diff --git a/cmd/server/config.go b/cmd/server/config.go new file mode 100644 index 0000000..8be11fc --- /dev/null +++ b/cmd/server/config.go @@ -0,0 +1,202 @@ +// Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC +// +// SPDX-License-Identifier: MIT + +package main + +import ( + "fmt" + "os" + "slices" + "strconv" + "strings" + + "github.com/OpenCHAMI/remote-console/internal/conman" + "github.com/OpenCHAMI/remote-console/internal/creds" + "github.com/OpenCHAMI/remote-console/internal/logs" +) + +type OAuth2Config struct { + ClientID string `desc:"OAuth2 client ID for SMD authentication"` + ClientSecret string `desc:"OAuth2 client secret for SMD authentication"` + TokenURL string `desc:"OAuth2 token endpoint URL for SMD authentication"` + Scopes []string `desc:"OAuth2 scopes for SMD authentication"` +} + +type remoteConsoleConfig struct { + Log logs.LogConfig `flag:"-"` + Conman conman.ConmanConfig + Creds creds.CredsConfig + HttpListen string `desc:"HTTP listen address"` + NewNodeLookup int `desc:"Interval in seconds to look for new nodes"` + CredsMonitorInterval int `desc:"Interval in seconds to monitor credential updates"` + SmdURL string `desc:"URL for the SMD service"` + JwksURL string `desc:"JWKS URL for fetching public keys for JWT validation (optional)"` + JwksFetchInterval int `desc:"Interval in seconds to retry fetching JWKS on failure"` + Oauth2 OAuth2Config +} + +func DefaultRemoteConsoleConfig() remoteConsoleConfig { + return remoteConsoleConfig{ + Log: logs.DefaultLogConfig(), + Conman: conman.DefaultConmanConfig(), + Creds: creds.DefaultCredsConfig(), + HttpListen: "0.0.0.0:26776", + NewNodeLookup: 120, + CredsMonitorInterval: 30, + SmdURL: "http://cray-smd/", + JwksURL: "", + JwksFetchInterval: 5, + // Note: Oauth2 vs OAuth2 so the sflags generate the correct flag name. + Oauth2: OAuth2Config{}, + } +} + +func ensureTrailingSlash(url string) string { + if url != "" && !strings.HasSuffix(url, "/") { + return url + "/" + } + return url +} + +func applyRemoteConsoleEnv(config *remoteConsoleConfig) error { + setString := func(key string, target *string) { + if value := os.Getenv(key); value != "" { + *target = value + } + } + setStringSlice := func(key string, target *[]string) { + if value := os.Getenv(key); value != "" { + *target = strings.Split(value, ",") + } + } + setInt := func(key string, target *int) error { + value := os.Getenv(key) + if value == "" { + return nil + } + parsed, err := strconv.Atoi(value) + if err != nil { + return fmt.Errorf("invalid %s value %q: %w", key, value, err) + } + *target = parsed + return nil + } + setBool := func(key string, target *bool) error { + value := os.Getenv(key) + if value == "" { + return nil + } + parsed, err := strconv.ParseBool(value) + if err != nil { + return fmt.Errorf("invalid %s value %q: %w", key, value, err) + } + *target = parsed + return nil + } + + setString("RCS_HTTP_LISTEN", &config.HttpListen) + setString("RCS_SMD_URL", &config.SmdURL) + setString("RCS_JWKS_URL", &config.JwksURL) + setString("RCS_OAUTH2_CLIENT_ID", &config.Oauth2.ClientID) + setString("RCS_OAUTH2_CLIENT_SECRET", &config.Oauth2.ClientSecret) + setString("RCS_OAUTH2_TOKEN_URL", &config.Oauth2.TokenURL) + setStringSlice("RCS_OAUTH2_SCOPES", &config.Oauth2.Scopes) + + setString("RCS_CONMAN_BASE_CONF_FILE_PATH", &config.Conman.BaseConfFilePath) + setString("RCS_CONMAN_CONF_FILE_PATH", &config.Conman.ConfFilePath) + setString("RCS_CONMAN_LOGS_PATH", &config.Conman.LogsPath) + setString("RCS_CONMAN_PID_FILE_PATH", &config.Conman.PidFilePath) + setString("RCS_CONMAN_CONSOLE_SCRIPTS_PATH", &config.Conman.ConsoleScriptsPath) + + setString("RCS_CREDS_SSH_CONSOLE_KEY_PATH", &config.Creds.SshConsoleKeyPath) + if value := os.Getenv("RCS_CREDS_SECURE_STORAGE_ADAPTER"); value != "" { + config.Creds.SecureStorageAdapter = creds.StorageAdapter(value) + } + setString("RCS_CREDS_VAULT_BASE_PATH", &config.Creds.VaultBasePath) + setString("RCS_CREDS_VAULT_ROLE", &config.Creds.VaultRole) + setString("RCS_CREDS_LOCAL_STORE_FILE_PATH", &config.Creds.LocalStoreFilePath) + setString("RCS_CREDS_LOCAL_STORE_KEY", &config.Creds.LocalStoreKey) + setString("RCS_CREDS_SECURE_STORAGE_SSH_KEYS_PATH", &config.Creds.SecureStorageSshKeysPath) + setString("RCS_CREDS_SECURE_STORAGE_PASSWORDS_PATH", &config.Creds.SecureStoragePasswordsPath) + + setString("RCS_CONSOLE_LOGS_FILE_SIZE", &config.Log.ConsoleLogsFileSize) + setString("RCS_CONSOLE_LOGS_BACKUP_PATH", &config.Log.ConsoleLogsBackupPath) + setString("RCS_AGG_LOGS_FILE_SIZE", &config.Log.AggLogsFileSize) + setString("RCS_AGG_LOGS_PATH", &config.Log.AggLogsPath) + setString("RCS_LOG_ROTATE_FILE_PATH", &config.Log.LogRotateFilePath) + setString("RCS_LOG_ROTATE_STATE_FILE_PATH", &config.Log.LogRotateStateFilePath) + + if err := setInt("RCS_NEW_NODE_LOOKUP", &config.NewNodeLookup); err != nil { + return err + } + if err := setInt("RCS_CREDS_MONITOR_INTERVAL", &config.CredsMonitorInterval); err != nil { + return err + } + if err := setInt("RCS_JWKS_FETCH_INTERVAL", &config.JwksFetchInterval); err != nil { + return err + } + if err := setInt("RCS_CONSOLE_LOGS_NUM_ROTATE", &config.Log.ConsoleLogsNumRotate); err != nil { + return err + } + if err := setInt("RCS_AGG_LOGS_NUM_ROTATE", &config.Log.AggLogsNumRotate); err != nil { + return err + } + if err := setInt("RCS_LOG_ROTATE_CHECK_FREQUENCY", &config.Log.LogRotateCheckFrequency); err != nil { + return err + } + if err := setBool("RCS_LOG_ROTATE_ENABLED", &config.Log.LogRotateEnabled); err != nil { + return err + } + + config.SmdURL = ensureTrailingSlash(config.SmdURL) + return validateConfig(config) +} + +func validateCredsConfig(config *remoteConsoleConfig) error { + credConfig := config.Creds + + if credConfig.SecureStorageAdapter != "" { + _, err := creds.NewStorageAdapter(string(credConfig.SecureStorageAdapter)) + if err != nil { + return fmt.Errorf("invalid secure storage adapter: %s, valid values are (vault or local)", credConfig.SecureStorageAdapter) + } + + if credConfig.SecureStorageAdapter == creds.StorageAdapterLocal { + if credConfig.LocalStoreFilePath == "" { + return fmt.Errorf("a local storage path must be set when using the local secure storage adapter") + } + + if credConfig.LocalStoreKey == "" { + return fmt.Errorf("a local storage key must be set when using the local secure storage adapter") + } + } + } + + return nil +} + +func validateConfig(config *remoteConsoleConfig) error { + if err := validateCredsConfig(config); err != nil { + return err + } + + // Validate OAuth2 configuration - either all or nothing. + oauth2 := config.Oauth2 + + oauth2Provided := []bool{ + oauth2.ClientID != "", + oauth2.ClientSecret != "", + oauth2.TokenURL != "", + len(oauth2.Scopes) > 0, + } + + allProvided := !slices.Contains(oauth2Provided, false) + noneProvided := !slices.Contains(oauth2Provided, true) + + if !allProvided && !noneProvided { + return fmt.Errorf("incomplete OAuth2 configuration: all fields (oauth2-client-id, oauth2-client-secret, oauth2-token-url and oauth2-scopes) must be provided") + } + + return nil +} diff --git a/cmd/server/console_handlers_generated.go b/cmd/server/console_handlers_generated.go new file mode 100644 index 0000000..bef2643 --- /dev/null +++ b/cmd/server/console_handlers_generated.go @@ -0,0 +1,47 @@ +// Code generated by Fabrica dev. DO NOT EDIT. +// Template: server/handlers.go.tmpl +// Generated: 2026-07-13T14:14:06Z +// +// Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC +// +// SPDX-License-Identifier: MIT +// +// This file contains REST API handlers for Console resources. +// +// To modify this code: +// 1. Edit the template file: pkg/codegen/templates/handlers.go.tmpl +// 2. Run 'fabrica generate' to regenerate +// 3. Do NOT edit this file directly - changes will be lost +// +// Generated handlers provide: +// - GET /remote-console/consoles (list all consoles) +// +// Authorization: Add custom middleware for authentication/authorization + +// Version Support: Available (see version context in handlers) +// +// To enable full version conversion for this resource: +// 1. Add new version: fabrica add version +// 2. Implement converter: apis///converter.go +// 3. Add version-aware storage: GetConsoleResourceWithVersion() +// 4. Register versions in cmd/server/main.go +package main + +import ( + "fmt" + + "net/http" +) + +// GetConsoles returns all Console resources +func GetConsoles(w http.ResponseWriter, r *http.Request) { + // Authorization: Add custom middleware in routes.go or implement checks here + // Example: if !authorized(r) { respondError(w, http.StatusUnauthorized, fmt.Errorf("unauthorized")); return } + + consoles, err := ListConsoleResources(r.Context()) + if err != nil { + respondError(w, http.StatusInternalServerError, fmt.Errorf("failed to load consoles: %w", err)) + return + } + respondJSON(w, http.StatusOK, consoles) +} diff --git a/cmd/server/console_persistence_hooks.go b/cmd/server/console_persistence_hooks.go new file mode 100644 index 0000000..e04737c --- /dev/null +++ b/cmd/server/console_persistence_hooks.go @@ -0,0 +1,50 @@ +// Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC +// +// SPDX-License-Identifier: MIT +package main + +import ( + "context" + "sort" + + v1 "github.com/OpenCHAMI/remote-console/apis/remote-console.openchami.io/v1" + "github.com/OpenCHAMI/remote-console/internal/nodes" + "github.com/openchami/fabrica/pkg/fabrica" +) + +func consoleFromNode(node *nodes.NodeConsoleInfo) *v1.Console { + return &v1.Console{ + APIVersion: "remote-console.openchami.io/v1", + Kind: "Console", + Metadata: fabrica.Metadata{ + Name: node.ID, + UID: node.ID, + }, + Spec: v1.ConsoleSpec{ + ConnectionType: node.ConnectionType, + ConnectionHost: node.ConnectionHost, + ConnectionPort: node.ConnectionPort, + ConsoleEntryCommand: node.ConsoleEntryCommand, + }, + } +} + +// ListConsoleResources supplies Console resources from discovered SMD Redfish data. +func ListConsoleResources(_ context.Context) ([]*v1.Console, error) { + currentNodes := nodes.CurrentNodes() + ids := make([]string, 0, len(currentNodes)) + for id := range currentNodes { + ids = append(ids, id) + } + sort.Strings(ids) + + consoles := make([]*v1.Console, 0, len(ids)) + for _, id := range ids { + if currentNodes[id] == nil { + continue + } + consoles = append(consoles, consoleFromNode(currentNodes[id])) + } + + return consoles, nil +} diff --git a/cmd/server/console_websocket.go b/cmd/server/console_websocket.go new file mode 100644 index 0000000..9ffd087 --- /dev/null +++ b/cmd/server/console_websocket.go @@ -0,0 +1,49 @@ +// Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC +// +// SPDX-License-Identifier: MIT + +package main + +import ( + "net/http" + "path/filepath" + "strings" + + "github.com/OpenCHAMI/remote-console/internal/console" +) + +var consoleWebSocketHandler *console.WebSocketHandler + +func initializeConsoleWebSocketHandler(config remoteConsoleConfig) { + consoleWebSocketHandler = console.NewWebSocketHandler(filepath.Join(config.Conman.LogsPath, "conman")) +} + +func isConsoleWebSocketRequest(r *http.Request) bool { + return strings.EqualFold(r.Header.Get("Upgrade"), "websocket") && + strings.Contains(strings.ToLower(r.Header.Get("Connection")), "upgrade") +} + +func consoleWebSocketMiddleware(webSocketHandler http.Handler) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if isConsoleWebSocketRequest(r) && isConsoleWebSocketPath(r.URL.Path) { + webSocketHandler.ServeHTTP(w, r) + return + } + next.ServeHTTP(w, r) + }) + } +} + +func isConsoleWebSocketPath(path string) bool { + uid := strings.TrimPrefix(path, "/remote-console/consoles/") + return uid != path && uid != "" && !strings.Contains(uid, "/") +} + +func serveConsoleWebSocket(w http.ResponseWriter, r *http.Request) { + if consoleWebSocketHandler == nil { + http.Error(w, "Console WebSocket handler is not initialized", http.StatusServiceUnavailable) + return + } + consoleWebSocketHandler.ServeHTTP(w, r) +} diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..5e55d4c --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,309 @@ +// Fabrica scaffold generated by Fabrica dev and customized for remote-console. +// Future `fabrica generate` runs update *_generated.go files, not this file. +// +// SPDX-FileCopyrightText: Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC +// +// SPDX-License-Identifier: MIT + +package main + +import ( + "context" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + + _ "github.com/OpenCHAMI/remote-console/pkg/apiversion" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/openchami/fabrica/pkg/versioning" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// Config holds all configuration for the service +type Config struct { + // Server Configuration + Port int `mapstructure:"port"` + Host string `mapstructure:"host"` + ReadTimeout int `mapstructure:"read_timeout"` + WriteTimeout int `mapstructure:"write_timeout"` + IdleTimeout int `mapstructure:"idle_timeout"` + + // Feature Flags + + AuthEnabled bool `mapstructure:"auth_enabled"` + Debug bool `mapstructure:"debug"` +} + +// DefaultConfig returns the default configuration +func DefaultConfig() *Config { + return &Config{ + Port: 26776, + Host: "0.0.0.0", + ReadTimeout: 15, + WriteTimeout: 15, + IdleTimeout: 60, + + AuthEnabled: true, + Debug: false, + } +} + +var ( + cfgFile string + config *Config +) + +func main() { + if err := rootCmd.Execute(); err != nil { + log.Fatal(err) + } +} + +var rootCmd = &cobra.Command{ + Use: "remote-console", + Short: "OpenCHAMI remote console service", + Long: `remote-console - A Fabrica-generated OpenCHAMI service`, + RunE: runServer, +} + +var serveCmd = &cobra.Command{ + Use: "serve", + Short: "Start the remote-console server", + Long: `Start the remote-console HTTP server with the configured options`, + RunE: runServer, +} + +func init() { + cobra.OnInitialize(initConfig) + + // Global flags + rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.remote-console.yaml)") + rootCmd.PersistentFlags().Bool("debug", false, "Enable debug logging") + + // Server flags + serveCmd.Flags().IntP("port", "p", 26776, "Port to listen on") + serveCmd.Flags().String("host", "0.0.0.0", "Host to bind to") + serveCmd.Flags().Int("read-timeout", 15, "Read timeout in seconds") + serveCmd.Flags().Int("write-timeout", 15, "Write timeout in seconds") + serveCmd.Flags().Int("idle-timeout", 60, "Idle timeout in seconds") + + serveCmd.Flags().Bool("auth-enabled", true, "Enable authentication") + + // Bind flags to viper + cobra.CheckErr(viper.BindPFlags(serveCmd.Flags())) + cobra.CheckErr(viper.BindPFlags(rootCmd.PersistentFlags())) + cobra.CheckErr(viper.BindPFlag("read_timeout", serveCmd.Flags().Lookup("read-timeout"))) + cobra.CheckErr(viper.BindPFlag("write_timeout", serveCmd.Flags().Lookup("write-timeout"))) + cobra.CheckErr(viper.BindPFlag("idle_timeout", serveCmd.Flags().Lookup("idle-timeout"))) + cobra.CheckErr(viper.BindPFlag("auth_enabled", serveCmd.Flags().Lookup("auth-enabled"))) + + // Add subcommands + rootCmd.AddCommand(serveCmd) + + rootCmd.AddCommand(versionCmd) + + // Generated by Fabrica v0.4.0+: export and import commands (Ent storage only) + +} + +func initConfig() { + config = DefaultConfig() + + if cfgFile != "" { + viper.SetConfigFile(cfgFile) + } else { + // Search for config in home directory + home, err := os.UserHomeDir() + cobra.CheckErr(err) + + viper.AddConfigPath(home) + viper.AddConfigPath(".") + viper.SetConfigType("yaml") + viper.SetConfigName(".remote-console") + } + + // Environment variables + viper.SetEnvPrefix("REMOTE_CONSOLE") + viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_", ".", "_")) + viper.AutomaticEnv() + + // Read config file if it exists + if err := viper.ReadInConfig(); err == nil { + log.Printf("Using config file: %s", viper.ConfigFileUsed()) + } + + // Unmarshal config + if err := viper.Unmarshal(config); err != nil { + log.Fatalf("Unable to decode into config struct: %v", err) + } + + // Set debug logging + if config.Debug { + log.SetFlags(log.LstdFlags | log.Lshortfile) + log.Println("Debug logging enabled") + } +} + +func runServer(cmd *cobra.Command, args []string) error { + log.Printf("Starting remote-console server...") + + storageCleanup, err := initializeStorage(config) + if err != nil { + return err + } + defer storageCleanup() + + runtimeCleanup, err := initializeEventingAndReconciliation(config) + if err != nil { + return err + } + defer runtimeCleanup() + + // Register resource prefixes for UID generation + // This is required before any handlers can create resources + if err := registerResourcePrefixes(); err != nil { + return fmt.Errorf("failed to register resource prefixes: %w", err) + } + + remoteConfig := DefaultRemoteConsoleConfig() + if os.Getenv("RCS_HTTP_LISTEN") == "" { + remoteConfig.HttpListen = fmt.Sprintf("%s:%d", config.Host, config.Port) + } + if err := applyRemoteConsoleEnv(&remoteConfig); err != nil { + return err + } + + serviceCtx, serviceStop := context.WithCancel(context.Background()) + defer serviceStop() + + if err := startRemoteConsoleRuntime(serviceCtx, remoteConfig); err != nil { + return err + } + initializeConsoleWebSocketHandler(remoteConfig) + + // Setup router + r := chi.NewRouter() + + // Add middleware + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + r.Use(middleware.RequestID) + r.Use(middleware.RealIP) + r.Use(middleware.RedirectSlashes) + + // Version negotiation (Accept header `version=` or URL strategy) + // Initialize and use global version registry when versioning is enabled. + // Note: VersionNegotiationMiddleware currently uses versioning.VersionRegistry + // Versioning support will be fully wired in a future update. + r.Use(versioning.VersionNegotiationMiddleware(versioning.GlobalVersionRegistry, nil)) + + authnMiddleware, authzMiddleware, err := initializeAuthMiddleware(config) + if err != nil { + return err + } + var consoleWebSocketEndpoint http.Handler = http.HandlerFunc(serveConsoleWebSocket) + if config.AuthEnabled { + consoleWebSocketEndpoint = authnMiddleware(consoleWebSocketEndpoint) + if authzMiddleware != nil { + consoleWebSocketEndpoint = authzMiddleware(consoleWebSocketEndpoint) + } + } + r.Use(consoleWebSocketMiddleware(consoleWebSocketEndpoint)) + + if config.Debug { + r.Mount("/debug", middleware.Profiler()) + } + + // Public endpoints (bypass AuthN/AuthZ) + r.Group(func(public chi.Router) { + // Service health (always public) + public.Get("/health", healthHandler) + public.Get("/openapi.json", ServeOpenAPISpec) + public.Get("/docs", ServeSwaggerUI) + if config.AuthEnabled { + // Allow CORS preflight without auth. + public.Method("OPTIONS", "/*", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + } + + }) + // Protected endpoints (API handlers) + r.Group(func(protected chi.Router) { + if config.AuthEnabled { + protected.Use(authnMiddleware) + if authzMiddleware != nil { + protected.Use(authzMiddleware) + } + } + + // Register routes - generated by 'fabrica generate' + RegisterGeneratedRoutes(protected) + }) + + initializeMetricsServer(config) + + // Create HTTP server + addr := remoteConfig.HttpListen + server := &http.Server{ + Addr: addr, + Handler: r, + ReadTimeout: time.Duration(config.ReadTimeout) * time.Second, + WriteTimeout: time.Duration(config.WriteTimeout) * time.Second, + IdleTimeout: time.Duration(config.IdleTimeout) * time.Second, + } + + // Start server in goroutine + go func() { + logStartupConfiguration(config, addr) + + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("Server failed: %v", err) + } + }() + + // Wait for interrupt signal for graceful shutdown + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + log.Println("Server shutting down...") + serviceStop() + + // Graceful shutdown with timeout + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := server.Shutdown(ctx); err != nil { + return fmt.Errorf("server forced to shutdown: %w", err) + } + + log.Println("Server exited") + return nil +} + +// Health check handler +func healthHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if _, err := w.Write([]byte(`{"status":"healthy","service":"remote-console"}`)); err != nil { + log.Printf("failed to write health response: %v", err) + } +} + +// registerResourcePrefixes is defined in routes_generated.go + +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Print the version number", + Long: `Print the version number of remote-console`, + Run: func(cmd *cobra.Command, args []string) { + fmt.Println("remote-console v1.0.0") + }, +} diff --git a/cmd/server/metrics_helpers_generated.go b/cmd/server/metrics_helpers_generated.go new file mode 100644 index 0000000..c5d2b63 --- /dev/null +++ b/cmd/server/metrics_helpers_generated.go @@ -0,0 +1,13 @@ +// Code generated by Fabrica dev. DO NOT EDIT. +// Template: init/metrics_helpers.go.tmpl +// Generated: 2026-07-08T18:41:14Z +// +// SPDX-FileCopyrightText: Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC +// +// SPDX-License-Identifier: MIT + +package main + +func initializeMetricsServer(config *Config) { + +} diff --git a/cmd/server/models_generated.go b/cmd/server/models_generated.go new file mode 100644 index 0000000..51b7aa2 --- /dev/null +++ b/cmd/server/models_generated.go @@ -0,0 +1,107 @@ +// Code generated by Fabrica dev. DO NOT EDIT. +// Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC +// +// SPDX-License-Identifier: MIT +// +// This file contains request/response models for REST API operations. +// Generated from: pkg/codegen/templates/models.go.tmpl +// +// To modify request/response models: +// 1. Edit the template file: pkg/codegen/templates/models.go.tmpl +// 2. Run 'fabrica generate' to regenerate +// 3. Do NOT edit this file directly - changes will be lost +// +// Generated models for each resource: +// - ResourceResponse: Single resource response (alias to resource type) +// - ResourcesResponse: List of resources with pagination info +// - CreateResourceRequest: Create operation request body +// - UpdateResourceRequest: Update operation request body +// - DeleteResponse: Delete operation response +// +// Request structure: +// - Embeds resource Spec fields inline (json:",inline") +// - Includes metadata fields (Name, Labels, Annotations) +// - Uses validation tags for required fields +// +// To add custom validation: +// 1. Add validation tags to template (e.g., validate:"required,email") +// 2. Implement validation middleware in handlers +// +// To add additional fields: +// 1. Modify Create/Update request structs in template +// 2. Update corresponding handlers to process new fields +package main + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/openchami/fabrica/pkg/fabrica" + + "github.com/OpenCHAMI/remote-console/apis/remote-console.openchami.io/v1" +) + +// ConsoleResponse represents the response for Console operations +type ConsoleResponse = v1.Console + +// CreateConsoleRequest represents a request to create a Console +type CreateConsoleRequest struct { + Metadata fabrica.Metadata `json:"metadata" yaml:"metadata" validate:"required"` + Spec v1.ConsoleSpec `json:"spec" yaml:"spec" validate:"required"` + Labels map[string]string `json:"labels,omitempty" yaml:"labels,omitempty"` + Annotations map[string]string `json:"annotations,omitempty" yaml:"annotations,omitempty"` +} + +// AsSpec converts the request fields to a spec object +func (r *CreateConsoleRequest) AsSpec() v1.ConsoleSpec { + return r.Spec +} + +// UpdateConsoleRequest represents a request to update a Console +type UpdateConsoleRequest struct { + Metadata fabrica.Metadata `json:"metadata,omitempty" yaml:"metadata,omitempty"` + Spec v1.ConsoleSpec `json:"spec,omitempty" yaml:"spec,omitempty" validate:"omitempty"` + Labels map[string]string `json:"labels,omitempty" yaml:"labels,omitempty"` + Annotations map[string]string `json:"annotations,omitempty" yaml:"annotations,omitempty"` +} + +// AsSpec converts the request fields to a spec object +func (r *UpdateConsoleRequest) AsSpec() v1.ConsoleSpec { + return r.Spec +} + +// ErrorResponse represents an error response +type ErrorResponse struct { + Error string `json:"error"` + Message string `json:"message,omitempty"` + Code int `json:"code,omitempty"` +} + +// DeleteResponse represents a successful deletion response +type DeleteResponse struct { + Message string `json:"message"` + UID string `json:"uid"` +} + +// Helper functions for handlers + +// respondJSON sends a JSON response +func respondJSON(w http.ResponseWriter, status int, data interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(data); err != nil { + http.Error(w, fmt.Sprintf("failed to encode response: %v", err), http.StatusInternalServerError) + } +} + +// respondError sends an error response +func respondError(w http.ResponseWriter, status int, err error) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + response := ErrorResponse{ + Error: err.Error(), + Code: status, + } + json.NewEncoder(w).Encode(response) +} diff --git a/cmd/server/openapi_extensions.go b/cmd/server/openapi_extensions.go new file mode 100644 index 0000000..7f92013 --- /dev/null +++ b/cmd/server/openapi_extensions.go @@ -0,0 +1,95 @@ +// Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC +// +// SPDX-License-Identifier: MIT +// +// This file contains the user-editable OpenAPI extension hook. +// +// ✅ This file is safe to edit: it will NOT be overwritten by regeneration. +// +// Add any routes that are not Fabrica-generated (legacy APIs, custom endpoints, +// WireGuard, cloud-init, etc.) to registerCustomOpenAPIPaths so they appear in +// the served OpenAPI spec and Swagger UI at /openapi.json and /docs. +// +// Example: +// +// func registerCustomOpenAPIPaths(spec *openapi3.T) { +// metaDataOp := openapi3.NewOperation() +// metaDataOp.OperationID = "getMetaData" +// metaDataOp.Summary = "Cloud-init meta-data endpoint" +// metaDataOp.Tags = []string{"cloud-init"} +// metaDataOp.Responses = openapi3.NewResponses() +// metaDataOp.Responses.Set("200", &openapi3.ResponseRef{ +// Value: openapi3.NewResponse().WithDescription("YAML metadata for the requesting node"), +// }) +// spec.Paths.Set("/meta-data", &openapi3.PathItem{Get: metaDataOp}) +// } +package main + +import "github.com/getkin/kin-openapi/openapi3" + +// registerCustomOpenAPIPaths is called by GenerateOpenAPISpec after all +// Fabrica-generated resource paths have been registered. +func registerCustomOpenAPIPaths(spec *openapi3.T) { + consoleWebSocketOp := openapi3.NewOperation() + consoleWebSocketOp.OperationID = "connectConsoleWebSocket" + consoleWebSocketOp.Summary = "Connect to a console WebSocket" + consoleWebSocketOp.Description = "Upgrades the request to a WebSocket for an interactive console session or console log tail. This is not a normal REST get-one Console operation; clients must send WebSocket Upgrade headers." + consoleWebSocketOp.Tags = []string{"Console"} + consoleWebSocketOp.Parameters = openapi3.Parameters{ + { + Value: &openapi3.Parameter{ + Name: "uid", + In: "path", + Required: true, + Description: "Console/node identifier.", + Schema: &openapi3.SchemaRef{Value: openapi3.NewStringSchema()}, + }, + }, + { + Value: &openapi3.Parameter{ + Name: "mode", + In: "query", + Required: false, + Description: "Console session mode. Defaults to interactive.", + Schema: &openapi3.SchemaRef{Value: openapi3.NewStringSchema(). + WithEnum("interactive", "tail")}, + }, + }, + { + Value: &openapi3.Parameter{ + Name: "follow", + In: "query", + Required: false, + Description: "Tail mode only. Continue streaming appended console log lines.", + Schema: &openapi3.SchemaRef{Value: openapi3.NewBoolSchema()}, + }, + }, + { + Value: &openapi3.Parameter{ + Name: "lines", + In: "query", + Required: false, + Description: "Tail mode only. Number of historical console log lines to return before following.", + Schema: &openapi3.SchemaRef{Value: openapi3.NewIntegerSchema()}, + }, + }, + } + consoleWebSocketOp.Responses = openapi3.NewResponses() + consoleWebSocketOp.Responses.Set("101", &openapi3.ResponseRef{ + Value: openapi3.NewResponse().WithDescription("Switching Protocols; WebSocket session established"), + }) + consoleWebSocketOp.Responses.Set("400", &openapi3.ResponseRef{ + Value: openapi3.NewResponse().WithDescription("Invalid console path or query parameter"), + }) + consoleWebSocketOp.Responses.Set("404", &openapi3.ResponseRef{ + Value: openapi3.NewResponse().WithDescription("Console node not found"), + }) + consoleWebSocketOp.Responses.Set("409", &openapi3.ResponseRef{ + Value: openapi3.NewResponse().WithDescription("Interactive console is already in use"), + }) + consoleWebSocketOp.Responses.Set("503", &openapi3.ResponseRef{ + Value: openapi3.NewResponse().WithDescription("Console WebSocket handler is not initialized"), + }) + + spec.Paths.Set("/remote-console/consoles/{uid}", &openapi3.PathItem{Get: consoleWebSocketOp}) +} diff --git a/cmd/server/openapi_generated.go b/cmd/server/openapi_generated.go new file mode 100644 index 0000000..89bfbdb --- /dev/null +++ b/cmd/server/openapi_generated.go @@ -0,0 +1,254 @@ +// Code generated by Fabrica. DO NOT EDIT. +// Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC +// +// SPDX-License-Identifier: MIT +// +// This file contains OpenAPI 3.0 specification generation for all resources. +// Generated from: pkg/codegen/templates/server/openapi.go.tmpl +// +// To modify OpenAPI spec: +// 1. Edit the template file: pkg/codegen/templates/server/openapi.go.tmpl +// 2. Run 'fabrica generate' to regenerate +// 3. Do NOT edit this file directly - changes will be lost +// +// OpenAPI endpoints: +// - GET /openapi.json - Returns OpenAPI 3.0 spec +// - GET /docs - Returns Swagger UI +// +// This file automatically generates OpenAPI schemas from Go types using +// kin-openapi's openapi3gen package. No docstring annotations required. +package main + +import ( + "encoding/json" + "net/http" + + "github.com/OpenCHAMI/remote-console/apis/remote-console.openchami.io/v1" + "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/openapi3gen" +) + +// ServeOpenAPISpec returns the OpenAPI 3.0 specification +func ServeOpenAPISpec(w http.ResponseWriter, r *http.Request) { + spec := GenerateOpenAPISpec() + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(spec); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +// ServeSwaggerUI returns the Swagger UI HTML page +func ServeSwaggerUI(w http.ResponseWriter, r *http.Request) { + html := ` + + + + remote_console API Documentation + + + + +
+ + + + +` + w.Header().Set("Content-Type", "text/html") + w.Write([]byte(html)) +} + +// GenerateOpenAPISpec generates the complete OpenAPI 3.0 specification +func GenerateOpenAPISpec() *openapi3.T { + spec := &openapi3.T{ + OpenAPI: "3.0.0", + Info: &openapi3.Info{ + Title: "remote_console API", + Description: "Fabrica-generated REST API with Kubernetes-style resource management", + Version: "1.0.0", + Contact: &openapi3.Contact{ + Name: "Fabrica Project", + URL: "https://github.com/openchami/fabrica", + }, + }, + Servers: openapi3.Servers{ + { + URL: "http://localhost:8080", + Description: "Development server", + }, + }, + Paths: openapi3.NewPaths(), + Components: &openapi3.Components{ + Schemas: make(openapi3.Schemas), + }, + } + + // Register all resource paths + registerConsolePaths(spec) + + // Register /health endpoint + healthOp := openapi3.NewOperation() + healthOp.OperationID = "health" + healthOp.Summary = "Service health" + healthOp.Description = "Returns service health information" + healthOp.Tags = []string{"Service"} + healthResponseSchema := openapi3.NewObjectSchema(). + WithProperty("status", openapi3.NewStringSchema()). + WithProperty("service", openapi3.NewStringSchema()). + WithProperty("version", openapi3.NewStringSchema()). + WithProperty("time", openapi3.NewStringSchema()). + WithRequired([]string{"status"}) + healthExample := map[string]interface{}{ + "status": "ok", + "service": "remote_console", + "version": "1.0.0", + "time": "2026-01-01T00:00:00Z", + } + healthResponseSchema.Example = healthExample + healthOp.Responses = openapi3.NewResponses() + healthOp.Responses.Set("200", &openapi3.ResponseRef{ + Value: openapi3.NewResponse(). + WithDescription("Healthy"). + WithContent(map[string]*openapi3.MediaType{ + "application/json": { + Schema: &openapi3.SchemaRef{Value: healthResponseSchema}, + Example: healthExample, + }, + }), + }) + spec.Paths.Set("/health", &openapi3.PathItem{Get: healthOp}) + + // Register /openapi.json endpoint (self-referential spec endpoint) + openAPIJSONExample := map[string]interface{}{ + "openapi": "3.0.0", + "info": map[string]interface{}{ + "title": "remote_console API", + "version": "1.0.0", + }, + } + openAPIJsonOp := openapi3.NewOperation() + openAPIJsonOp.OperationID = "getOpenAPISpec" + openAPIJsonOp.Summary = "Get OpenAPI specification" + openAPIJsonOp.Description = "Returns the complete OpenAPI 3.0 specification for this API" + openAPIJsonOp.Tags = []string{"Service"} + openAPIJsonOp.Responses = openapi3.NewResponses() + openAPIJsonOp.Responses.Set("200", &openapi3.ResponseRef{ + Value: openapi3.NewResponse(). + WithDescription("OpenAPI specification"). + WithContent(map[string]*openapi3.MediaType{ + "application/json": { + Schema: &openapi3.SchemaRef{Value: openapi3.NewObjectSchema()}, + Example: openAPIJSONExample, + }, + }), + }) + spec.Paths.Set("/openapi.json", &openapi3.PathItem{Get: openAPIJsonOp}) + + // Register /docs endpoint (Swagger UI) + docsOp := openapi3.NewOperation() + docsOp.OperationID = "getSwaggerUI" + docsOp.Summary = "API documentation UI" + docsOp.Description = "Returns an interactive Swagger UI for exploring the API" + docsOp.Tags = []string{"Service"} + docsOp.Responses = openapi3.NewResponses() + docsOp.Responses.Set("200", &openapi3.ResponseRef{ + Value: openapi3.NewResponse(). + WithDescription("Swagger UI HTML page"). + WithContent(map[string]*openapi3.MediaType{ + "text/html": { + Schema: &openapi3.SchemaRef{Value: openapi3.NewStringSchema()}, + }, + }), + }) + spec.Paths.Set("/docs", &openapi3.PathItem{Get: docsOp}) + + // Register custom (non-Fabrica-generated) paths. + // Defined in openapi_extensions.go – safe to edit, never overwritten. + registerCustomOpenAPIPaths(spec) + + return spec +} + +// registerConsolePaths registers OpenAPI paths for Console resources +func registerConsolePaths(spec *openapi3.T) { + // Generate schemas from Go types - NO ANNOTATIONS NEEDED + resourceSchema, _ := openapi3gen.NewSchemaRefForValue(&v1.Console{}, spec.Components.Schemas) + spec.Components.Schemas["Console"] = resourceSchema + + // Error response schema + if _, exists := spec.Components.Schemas["ErrorResponse"]; !exists { + errorSchema := openapi3.NewObjectSchema(). + WithProperty("error", openapi3.NewStringSchema()). + WithRequired([]string{"error"}) + spec.Components.Schemas["ErrorResponse"] = &openapi3.SchemaRef{Value: errorSchema} + } + // List Consoles operation + listOp := openapi3.NewOperation() + listOp.OperationID = "listConsoles" + listOp.Summary = "List all Console resources" + listOp.Description = "Returns a list of all Console resources in the inventory" + listOp.Tags = []string{"Console"} + listOp.Responses = openapi3.NewResponses() + arraySchema := openapi3.NewArraySchema() + arraySchema.Items = &openapi3.SchemaRef{Ref: "#/components/schemas/Console"} + listOp.Responses.Set("200", &openapi3.ResponseRef{ + Value: openapi3.NewResponse(). + WithDescription("Successful response"). + WithJSONSchemaRef(&openapi3.SchemaRef{Value: arraySchema}), + }) + listOp.Responses.Set("500", errorResponse()) + + // Create path items + collectionPath := &openapi3.PathItem{ + Get: listOp, + } + + // Add paths to spec + spec.Paths.Set("/remote-console/consoles", collectionPath) +} + +func patchRequestBody() *openapi3.RequestBodyRef { + patchSchema := &openapi3.SchemaRef{Value: openapi3.NewObjectSchema()} + return &openapi3.RequestBodyRef{ + Value: &openapi3.RequestBody{ + Required: true, + Content: openapi3.Content{ + "application/merge-patch+json": &openapi3.MediaType{Schema: patchSchema}, + "application/json-patch+json": &openapi3.MediaType{Schema: patchSchema}, + "application/shorthand-patch": &openapi3.MediaType{Schema: patchSchema}, + "application/json": &openapi3.MediaType{Schema: patchSchema}, + }, + }, + } +} + +// Helper function for error responses +func errorResponse() *openapi3.ResponseRef { + return &openapi3.ResponseRef{ + Value: openapi3.NewResponse(). + WithDescription("Error response"). + WithJSONSchemaRef(&openapi3.SchemaRef{ + Ref: "#/components/schemas/ErrorResponse", + }), + } +} diff --git a/cmd/server/routes_generated.go b/cmd/server/routes_generated.go new file mode 100644 index 0000000..1e4369d --- /dev/null +++ b/cmd/server/routes_generated.go @@ -0,0 +1,57 @@ +// Code generated by Fabrica. DO NOT EDIT. +// Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC +// +// SPDX-License-Identifier: MIT +// +// This file contains REST API route registrations for all resources. +// Generated from: pkg/codegen/templates/routes.go.tmpl +// +// To modify route registration: +// 1. Edit the template file: pkg/codegen/templates/routes.go.tmpl +// 2. Run 'fabrica generate' to regenerate +// 3. Do NOT edit this file directly - changes will be lost +// +// This file registers routes for all resource types: +// - /remote-console/consoles (Console operations) +// +// Route patterns: +// - GET /remote-console/consoles -> List consoles +// +// To add middleware to routes: +// 1. Apply middleware in cmd/server/main.go before calling RegisterGeneratedRoutes +// 2. Use r.Use() calls in main.go, not in generated route functions +// +// To add custom routes: +// 1. Create a separate RegisterCustomRoutes function +// 2. Call it after RegisterGeneratedRoutes in main.go +package main + +import ( + "github.com/go-chi/chi/v5" + "github.com/openchami/fabrica/pkg/resource" +) + +// registerResourcePrefixes registers UID prefixes for all resource types +// This must be called before any resource creation operations +func registerResourcePrefixes() error { + + resource.RegisterResourcePrefix("Console", "console") + + return nil +} + +// RegisterGeneratedRoutes registers all generated resource routes. +// Note: Middleware should be applied in main.go before calling this function. +// Public service routes such as /health, /openapi.json, and /docs are registered +// in cmd/server/main.go so they remain outside auth middleware. +func RegisterGeneratedRoutes(r chi.Router) { + // Protected endpoints (resource APIs) + r.Group(func(protected chi.Router) { + + // Console routes + protected.Route("/remote-console/consoles", func(r chi.Router) { + r.Get("/", GetConsoles) + }) + + }) +} diff --git a/cmd/server/runtime_helpers_generated.go b/cmd/server/runtime_helpers_generated.go new file mode 100644 index 0000000..47c8fd6 --- /dev/null +++ b/cmd/server/runtime_helpers_generated.go @@ -0,0 +1,28 @@ +// Code generated by Fabrica dev. DO NOT EDIT. +// Template: init/runtime_helpers.go.tmpl +// Generated: 2026-07-08T18:41:14Z +// +// SPDX-FileCopyrightText: Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC +// +// SPDX-License-Identifier: MIT + +package main + +import "log" + +func initializeStorage(config *Config) (func(), error) { + + return func() {}, nil + +} + +func initializeEventingAndReconciliation(config *Config) (func(), error) { + + return func() {}, nil + +} + +func logStartupConfiguration(config *Config, addr string) { + log.Printf("Server starting on %s", addr) + +} diff --git a/cmd/remote-console/service.go b/cmd/server/service.go similarity index 84% rename from cmd/remote-console/service.go rename to cmd/server/service.go index dc8746f..43d180d 100644 --- a/cmd/remote-console/service.go +++ b/cmd/server/service.go @@ -11,9 +11,7 @@ import ( "log/slog" "net/http" "os" - "os/signal" "path/filepath" - "syscall" "time" compcreds "github.com/Cray-HPE/hms-compcredentials" @@ -28,7 +26,7 @@ import ( const smdHTTPTimeout = 15 * time.Second -// ConmanService defines the interface for conman service operations +// ConmanService defines the interface for conman service operations. type ConmanService interface { ConfigureConman(nodes map[string]*nodes.NodeConsoleInfo, passwords map[string]compcreds.CompCredentials, sshConsoleKeyPath string) (bool, error) ExecuteConman() error @@ -36,14 +34,14 @@ type ConmanService interface { SignalConmanHUP() error } -// CredsService defines the interface for credentials service operations +// CredsService defines the interface for credentials service operations. type CredsService interface { GetPasswordsWithRetries(ctx context.Context, bmcXNames []string, maxTries, waitSecs int) (map[string]compcreds.CompCredentials, error) EnsureConsoleKeysPresent() (bool, error) CheckForUpdates() (bool, error) } -// LogsService defines the interface for logs service operations +// LogsService defines the interface for logs service operations. type LogsService interface { UpdateLogRotateConf(consoleLogsPath string, nodes map[string]*nodes.NodeConsoleInfo) error LogRotate(consoleLogsPath string) bool @@ -223,8 +221,7 @@ func runConman(ctx context.Context, config remoteConsoleConfig, conmanService Co } } -func runService(config remoteConsoleConfig) error { - +func startRemoteConsoleRuntime(serviceCtx context.Context, config remoteConsoleConfig) error { slog.Info("Remote console service starting") // Set up the zombie killer slog.Info("Starting zombie killer") @@ -253,9 +250,6 @@ func runService(config remoteConsoleConfig) error { slog.Warn("Failed to ensure console SSH keys present", "error", err) } - // Create service context for coordinating shutdown of background goroutines - serviceCtx, serviceStopCtx := context.WithCancel(context.Background()) - // Configure HTTP client for SMD requests var smdHTTPClient *http.Client if config.Oauth2.TokenURL != "" { @@ -328,67 +322,11 @@ func runService(config remoteConsoleConfig) error { // JWKS URL was explicitly provided but we couldn't fetch it // This is a fatal error - don't start with unprotected endpoints slog.Error("Failed to initialize JWT authentication after all retries - refusing to start with unprotected endpoints") - serviceStopCtx() return fmt.Errorf("failed to fetch JWKS from %s: %w", config.JwksURL, lastErr) } } else { slog.Warn("No JWKS URL provided - JWT authentication is disabled") } - // Setup a channel to wait for the os to tell us to stop. - // NOTE - This must be set up before initializing anything that needs - // to be cleaned up. This will trap any signals and wait to - // process them until the channel is read. - sigs := make(chan os.Signal, 1) - signal.Notify(sigs, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM) - - router := console.SetupRoutes(conmanLogsPath) - - slog.Info("Starting HTTP server", "address", config.HttpListen) - server := &http.Server{Addr: config.HttpListen, Handler: router} - - // Signal to cleanly shut down - go func() { - - // NOTE: do not use log.Fatal as that will immediately exit - // the program and short-circuit the shutdown logic below - slog.Info("Server started", "result", server.ListenAndServe()) - }() - - serverCtx, serverStopCtx := context.WithCancel(context.Background()) - - // Listen for syscall signals for process to interrupt/quit - go func() { - sig := <-sigs - slog.Info("Detected signal to close service", "signal", sig) - - // Cancel service context to stop background goroutines - serviceStopCtx() - - // Shutdown signal with grace period of 30 seconds - shutdownCtx, shutdownCtxCancel := context.WithTimeout(context.Background(), 30*time.Second) - - go func() { - <-shutdownCtx.Done() - if shutdownCtx.Err() == context.DeadlineExceeded { - shutdownCtxCancel() - slog.Error("Graceful shutdown timed out, forcing exit") - os.Exit(1) - } - }() - - // Trigger graceful shutdown - err := server.Shutdown(shutdownCtx) - if err != nil { - slog.Error("Failed to shutdown HTTP server gracefully", "error", err) - os.Exit(1) - } - serverStopCtx() - }() - - // Wait for server context to be stopped - <-serverCtx.Done() - slog.Info("Shutdown complete") - return nil } diff --git a/docker-compose-devel.yaml b/docker-compose-devel.yaml index 5eff3fb..de5f4f4 100644 --- a/docker-compose-devel.yaml +++ b/docker-compose-devel.yaml @@ -153,7 +153,11 @@ services: build: context: . dockerfile: Dockerfile + target: final + command: ["serve"] + working_dir: /tmp environment: + REMOTE_CONSOLE_AUTH_ENABLED: "false" RCS_SMD_URL: "http://smd:27779" SMS_SERVER: "http://smd:27779" CRAY_VAULT_AUTH_PATH: "auth/token/create" diff --git a/go.mod b/go.mod index 99f1cec..6de87c1 100644 --- a/go.mod +++ b/go.mod @@ -22,33 +22,44 @@ module github.com/OpenCHAMI/remote-console -go 1.26.0 +go 1.26.4 require ( github.com/Cray-HPE/hms-compcredentials v1.15.0 github.com/Cray-HPE/hms-securestorage v1.18.0 github.com/OpenCHAMI/jwtauth/v5 v5.0.0-20240321222802-e6cb468a2a18 github.com/creack/pty v1.1.24 + github.com/getkin/kin-openapi v0.140.0 github.com/go-chi/chi/v5 v5.2.4 github.com/gorilla/websocket v1.5.0 github.com/lestrrat-go/jwx v1.2.31 github.com/nxadm/tail v1.4.11 - github.com/openchami/chi-middleware/auth v0.0.0-20240812224658-b16b83c70700 + github.com/openchami/fabrica v0.4.8 + github.com/openchami/tokensmith v0.3.0 + github.com/spf13/cobra v1.10.2 + github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 github.com/testcontainers/testcontainers-go v0.40.0 github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0 github.com/testcontainers/testcontainers-go/modules/vault v0.40.0 - github.com/urfave/cli/v3 v3.5.0 - github.com/urfave/sflags v0.4.1 - golang.org/x/crypto v0.52.0 - golang.org/x/oauth2 v0.34.0 - golang.org/x/sys v0.45.0 + golang.org/x/crypto v0.53.0 + golang.org/x/oauth2 v0.36.0 + golang.org/x/sys v0.46.0 + golang.org/x/term v0.44.0 + gopkg.in/yaml.v3 v3.0.1 ) +replace github.com/openchami/fabrica => github.com/cjh1/fabrica v0.0.0-20260713144149-cbbc9e5a44cc + require ( dario.cat/mergo v1.0.2 // indirect github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect + github.com/MicahParks/jwkset v0.11.0 // indirect + github.com/MicahParks/keyfunc/v3 v3.8.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/bmatcuk/doublestar/v4 v4.6.1 // indirect + github.com/casbin/casbin/v2 v2.135.0 // indirect + github.com/casbin/govaluate v1.3.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect @@ -56,7 +67,6 @@ require ( github.com/containerd/log v0.1.0 // indirect github.com/containerd/platforms v0.2.1 // indirect github.com/cpuguy83/dockercfg v0.3.2 // indirect - github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/distribution/reference v0.6.0 // indirect @@ -66,11 +76,20 @@ require ( github.com/ebitengine/purego v0.8.4 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.2.6 // indirect + github.com/go-openapi/jsonpointer v0.22.5 // indirect + github.com/go-openapi/swag/jsonname v0.25.5 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.3 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/goccy/go-json v0.10.3 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/google/jsonschema-go v0.4.3 // indirect github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect @@ -80,9 +99,12 @@ require ( github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect github.com/hashicorp/go-sockaddr v1.0.7 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/hashicorp/vault/api v1.16.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/klauspost/compress v1.18.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect github.com/lestrrat-go/backoff/v2 v2.0.8 // indirect github.com/lestrrat-go/blackmagic v1.0.2 // indirect github.com/lestrrat-go/httpcc v1.0.1 // indirect @@ -92,6 +114,8 @@ require ( github.com/lestrrat-go/option v1.0.1 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/magiconair/properties v1.8.10 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect @@ -101,21 +125,32 @@ require ( github.com/moby/sys/user v0.4.0 // indirect github.com/moby/sys/userns v0.1.0 // indirect github.com/moby/term v0.5.0 // indirect + github.com/modelcontextprotocol/go-sdk v1.6.1 // indirect github.com/morikuni/aec v1.0.0 // indirect + github.com/oasdiff/yaml v0.1.0 // indirect + github.com/oasdiff/yaml3 v0.0.13 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect - github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/rs/zerolog v1.35.1 // indirect github.com/ryanuber/go-glob v1.0.0 // indirect - github.com/segmentio/asm v1.2.0 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect + github.com/segmentio/asm v1.2.1 // indirect + github.com/segmentio/encoding v0.5.4 // indirect github.com/shirou/gopsutil/v4 v4.25.6 // indirect github.com/sirupsen/logrus v1.9.3 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/subosito/gotenv v1.6.0 // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect - github.com/urfave/cli/v2 v2.27.5 // indirect - github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect @@ -125,10 +160,10 @@ require ( go.opentelemetry.io/otel/sdk v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect go.opentelemetry.io/proto/otlp v1.9.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/net v0.55.0 // indirect - golang.org/x/text v0.37.0 // indirect - golang.org/x/time v0.11.0 // indirect + golang.org/x/text v0.38.0 // indirect + golang.org/x/time v0.12.0 // indirect google.golang.org/protobuf v1.36.10 // indirect gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 6a58f30..ec492e8 100644 --- a/go.sum +++ b/go.sum @@ -8,14 +8,26 @@ github.com/Cray-HPE/hms-compcredentials v1.15.0 h1:ao3oWw/+yo9rrRpUOpVfew7kyLhra github.com/Cray-HPE/hms-compcredentials v1.15.0/go.mod h1:Qofdt6WFcY97J6V4nGEDfFSxIE9ZZJzoZDsTBfjQ2RY= github.com/Cray-HPE/hms-securestorage v1.18.0 h1:m6cftLpyNkkDXR/3aSIiKtKPmydDX3lbxM++6XMczjQ= github.com/Cray-HPE/hms-securestorage v1.18.0/go.mod h1:67L1kYEJTRYnxmDTvbrM6aSOwjc4+5SqtFOZDsp9IB4= +github.com/MicahParks/jwkset v0.11.0 h1:yc0zG+jCvZpWgFDFmvs8/8jqqVBG9oyIbmBtmjOhoyQ= +github.com/MicahParks/jwkset v0.11.0/go.mod h1:U2oRhRaLgDCLjtpGL2GseNKGmZtLs/3O7p+OZaL5vo0= +github.com/MicahParks/keyfunc/v3 v3.8.0 h1:Hx2dgIjAXGk9slakM6rV9BOeaWDPEXXZ4Us8guNBfds= +github.com/MicahParks/keyfunc/v3 v3.8.0/go.mod h1:z66bkCviwqfg2YUp+Jcc/xRE9IXLcMq6DrgV/+Htru0= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/OpenCHAMI/jwtauth/v5 v5.0.0-20240321222802-e6cb468a2a18 h1:oBPtXp9RVm9lk5zTmDLf+Vh21yDHpulBxUqGJQjwQCk= github.com/OpenCHAMI/jwtauth/v5 v5.0.0-20240321222802-e6cb468a2a18/go.mod h1:ggNHWgLfW/WRXcE8ZZC4S7UwHif16HVmyowOCWdNSN8= +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/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk= +github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18= +github.com/casbin/govaluate v1.3.0 h1:VA0eSY0M2lA86dYd5kPPuNZMUD9QkWnOCnavGrw9myc= +github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cjh1/fabrica v0.0.0-20260713144149-cbbc9e5a44cc h1:3xRwTVNIwG7XWSIypbSf6/AD4nuMOlYXWjFTwfVimYw= +github.com/cjh1/fabrica v0.0.0-20260713144149-cbbc9e5a44cc/go.mod h1:ZSnr/5U9zH/KSJcPE6pPHECNnwXIyiBtBBiInXD9w/c= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= @@ -26,8 +38,7 @@ github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpS github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= -github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc= -github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -38,6 +49,8 @@ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvw github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/docker/docker v28.5.1+incompatible h1:Bm8DchhSD2J6PsFzxC35TZo4TLGR2PdW/E69rU45NhM= github.com/docker/docker v28.5.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= @@ -50,9 +63,15 @@ github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= +github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/getkin/kin-openapi v0.140.0 h1:JFn675aXRFjyiZKa/BFWploGldQlI0gobp4J5k0EZ2g= +github.com/getkin/kin-openapi v0.140.0/go.mod h1:lISrB64F0CPcuDJ3LdtPTMJBY8VENjR9wJBdrcT6J3g= github.com/go-chi/chi/v5 v5.2.4 h1:WtFKPHwlywe8Srng8j2BhOD9312j9cGUxG1SP4V2cR4= github.com/go-chi/chi/v5 v5.2.4/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= @@ -64,13 +83,37 @@ 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-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA= +github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0= +github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo= +github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU= +github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM= +github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8= +github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc= github.com/go-test/deep v1.0.2 h1:onZX1rnHT3Wv6cqNgYyFOOlgVKJrksuCMCRvJStbMYw= github.com/go-test/deep v1.0.2/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc= +github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= 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/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= @@ -96,12 +139,16 @@ github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9 github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hashicorp/vault-client-go v0.4.3 h1:zG7STGVgn/VK6rnZc0k8PGbfv2x/sJExRKHSUg3ljWc= github.com/hashicorp/vault-client-go v0.4.3/go.mod h1:4tDw7Uhq5XOxS1fO+oMtotHL7j4sB9cp0T7U6m4FzDY= github.com/hashicorp/vault/api v1.16.0 h1:nbEYGJiAPGzT9U4oWgaaB0g+Rj8E59QuHKyA5LhwQN4= github.com/hashicorp/vault/api v1.16.0/go.mod h1:KhuUhzOD8lDSk29AtzNjgAu2kxRA9jL9NAbkFlqvkBA= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= @@ -116,6 +163,8 @@ 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/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lestrrat-go/backoff/v2 v2.0.8 h1:oNb5E5isby2kiro9AgdHLv5N5tint1AnDVVf2E2un5A= github.com/lestrrat-go/backoff/v2 v2.0.8/go.mod h1:rHP/q/r9aT27n24JQLa7JhSQZCKBBOiM/uP402WwN8Y= github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k= @@ -139,8 +188,8 @@ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= @@ -165,16 +214,24 @@ github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU= +github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY= github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc= -github.com/openchami/chi-middleware/auth v0.0.0-20240812224658-b16b83c70700 h1:XADGipD2FZ9swuFUqeL7h63j3voiq9qA7P0aKsqgZKg= -github.com/openchami/chi-middleware/auth v0.0.0-20240812224658-b16b83c70700/go.mod h1:kswb9kU5cZAFRAvf1dAUJRWbQyjDEb0qkxW4ncDdEXg= +github.com/oasdiff/yaml v0.1.0 h1:0bqZjfKc/8S9urj4JuwepX41WX9EoA6ifhU3SV06cXg= +github.com/oasdiff/yaml v0.1.0/go.mod h1:kOlRmMdL2X3vucLCEQO5u61SU22RysnfXvcttrZA1O0= +github.com/oasdiff/yaml3 v0.0.13 h1:06svmvOHOVBqF81+sY2EUScvUI/iS/vl2VIeUUxZQwg= +github.com/oasdiff/yaml3 v0.0.13/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= +github.com/openchami/tokensmith v0.3.0 h1:OdP1sQo4dXfGzWC4NEtUvfOcRxLdqR91P2yy0gRyAXQ= +github.com/openchami/tokensmith v0.3.0/go.mod h1:L4ZCMX/vPGwXUUn9otw+UdfFTbarv+ZVO/FjhZmoOAE= 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.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -182,18 +239,38 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= -github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= -github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= +github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= +github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= +github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= -github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= -github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs= github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= @@ -202,6 +279,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU= github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY= github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0 h1:s2bIayFXlbDFexo96y+htn7FzuhpXLYJNnIuglNKqOk= @@ -218,14 +297,8 @@ github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFA github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= -github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w= -github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= -github.com/urfave/cli/v3 v3.5.0 h1:qCuFMmdayTF3zmjG8TSsoBzrDqszNrklYg2x3g4MSgw= -github.com/urfave/cli/v3 v3.5.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= -github.com/urfave/sflags v0.4.1 h1:9BKteZiMaLlgfMm8eYbFge3eRAUsrJXs4HsCemdDl+A= -github.com/urfave/sflags v0.4.1/go.mod h1:NCIz2mBC+woyrkl88PeiKAuQUKJdEre2Y4at5SreAeU= -github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= -github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= @@ -246,29 +319,38 @@ go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09 go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= -golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= -golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= -golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= -golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= -golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= -golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY= google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE= diff --git a/internal/console/common.go b/internal/console/common.go index 3e6ef99..52594d1 100644 --- a/internal/console/common.go +++ b/internal/console/common.go @@ -9,9 +9,8 @@ import ( "io" "log/slog" "net/http" + "strings" "time" - - "github.com/go-chi/chi/v5" ) // Rate limiter constants for console output @@ -31,7 +30,12 @@ func drainAndCloseRequestBody(req *http.Request) { } func extractNodeId(r *http.Request) (string, error) { - nodeID := chi.URLParam(r, "nodeID") + const consolePathPrefix = routePrefix + "/consoles/" + + nodeID := strings.TrimPrefix(r.URL.Path, consolePathPrefix) + if nodeID == r.URL.Path || strings.Contains(nodeID, "/") { + nodeID = "" + } if nodeID == "" { slog.Error("Failed to extract node ID from request", "path", r.URL.Path) return "", fmt.Errorf("unable to extract node ID") diff --git a/internal/console/consoles.go b/internal/console/consoles.go deleted file mode 100644 index f2a32dc..0000000 --- a/internal/console/consoles.go +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC -// -// SPDX-License-Identifier: MIT - -package console - -import ( - "net/http" - - "github.com/OpenCHAMI/remote-console/internal/nodes" -) - -type ConsolesResponse struct { - Consoles []nodes.NodeConsoleInfo `json:"consoles"` -} - -// doConsoles handles the /consoles endpoint to list all available consoles -func doConsoles(w http.ResponseWriter, r *http.Request) { - // get the current list of consoles - nodeList := nodes.CurrentNodes() - var resp ConsolesResponse - for _, consoleInfo := range nodeList { - resp.Consoles = append(resp.Consoles, *consoleInfo) - } - - // write the output - sendResponseJSON(w, http.StatusOK, resp) -} diff --git a/internal/console/health.go b/internal/console/health.go deleted file mode 100644 index 537dc98..0000000 --- a/internal/console/health.go +++ /dev/null @@ -1,106 +0,0 @@ -// Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC -// Copyright © 2021 - 2024 Hewlett Packard Enterprise Development LP -// -// SPDX-License-Identifier: MIT - -// This file contains the health endpoint functions - -package console - -import ( - "fmt" - "log/slog" - "net/http" - - "github.com/OpenCHAMI/remote-console/internal/nodes" -) - -// HealthResponse - used to report service health stats -type HealthResponse struct { - NumberConsoles string `json:"consoles"` - LastHardwareUpdate string `json:"hardwareupdate"` -} - -type errorResponse struct { - E int `json:"e"` - ErrMsg string `json:"err_msg"` -} - -func sendJSONError(w http.ResponseWriter, ecode int, message string) { - httpCode := ecode - if ecode >= 200 && ecode <= 299 { - ecode = 0 - } - data := errorResponse{ - E: ecode, - ErrMsg: message, - } - sendResponseJSON(w, httpCode, data) -} - -// Debugging information query -func doHealth(w http.ResponseWriter, r *http.Request) { - // NOTE: this is provided as a quick check of the internal status for - // administrators to aid in determining the health of this service. - - // only allow 'GET' calls - if r.Method != http.MethodGet { - w.Header().Set("Allow", "GET") - sendJSONError(w, http.StatusMethodNotAllowed, - fmt.Sprintf("(%s) Not Allowed", r.Method)) - return - } - - // get the current health status - stats := getCurrentHealth() - - // log the query - slog.Debug("Health check", "consoles", stats.NumberConsoles, "lastUpdate", stats.LastHardwareUpdate) - - // write the output - sendResponseJSON(w, http.StatusOK, stats) -} - -// Fill out the current status of a HealthResponse object -func getCurrentHealth() HealthResponse { - var stats HealthResponse - stats.LastHardwareUpdate = nodes.GetHardwareUpdateTime() - stats.NumberConsoles = fmt.Sprintf("%d", len(nodes.CurrentNodes())) - return stats -} - -// Basic liveness probe -func doLiveness(w http.ResponseWriter, r *http.Request) { - // NOTE: this is coded in accordance with kubernetes best practices - // for liveness/readiness checks. This function should only be - // used to indicate the server is still alive and processing requests. - - // only allow 'GET' calls - if r.Method != http.MethodGet { - w.Header().Set("Allow", "GET") - sendJSONError(w, http.StatusMethodNotAllowed, - fmt.Sprintf("(%s) Not Allowed", r.Method)) - return - } - - // return simple StatusOK response to indicate server is alive - w.WriteHeader(http.StatusNoContent) -} - -// Basic readiness probe -func doReadiness(w http.ResponseWriter, r *http.Request) { - // NOTE: this is coded in accordance with kubernetes best practices - // for liveness/readiness checks. This function should only be - // used to indicate the server is still alive and processing requests. - - // only allow 'GET' calls - if r.Method != http.MethodGet { - w.Header().Set("Allow", "GET") - sendJSONError(w, http.StatusMethodNotAllowed, - fmt.Sprintf("(%s) Not Allowed", r.Method)) - return - } - - // return simple StatusOK response to indicate server is alive - w.WriteHeader(http.StatusNoContent) -} diff --git a/internal/console/http.go b/internal/console/http.go deleted file mode 100644 index aea050e..0000000 --- a/internal/console/http.go +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC -// Copyright © 2019 - 2025 Hewlett Packard Enterprise Development LP -// -// SPDX-License-Identifier: MIT - -package console - -import ( - "encoding/json" - "log/slog" - "net/http" -) - -func sendResponseJSON(w http.ResponseWriter, sc int, data interface{}) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(sc) - err := json.NewEncoder(w).Encode(data) - if err != nil { - slog.Error("encoding/sending JSON response", "error", err) - return - } -} diff --git a/internal/console/router.go b/internal/console/router.go index abd6a98..9651aad 100644 --- a/internal/console/router.go +++ b/internal/console/router.go @@ -7,14 +7,9 @@ package console import ( "fmt" - "log/slog" "net/http" - "github.com/OpenCHAMI/jwtauth/v5" - "github.com/go-chi/chi/v5" - "github.com/go-chi/chi/v5/middleware" "github.com/gorilla/websocket" - openchami_authenticator "github.com/openchami/chi-middleware/auth" ) const routePrefix = "/remote-console" @@ -28,6 +23,24 @@ var upgrader = websocket.Upgrader{ }, } +// WebSocketHandler adapts the legacy console WebSocket implementation for +// servers whose routes are registered elsewhere. +type WebSocketHandler struct { + consoleLogsPath string + sessions *interactiveSessions +} + +func NewWebSocketHandler(consoleLogsPath string) *WebSocketHandler { + return &WebSocketHandler{ + consoleLogsPath: consoleLogsPath, + sessions: newInteractiveSessions(), + } +} + +func (h *WebSocketHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + doConsole(h.consoleLogsPath, h.sessions, w, r) +} + // doConsole dispatches to either interactive or tail mode based on the mode query parameter func doConsole(consoleLogsPath string, sessions *interactiveSessions, w http.ResponseWriter, r *http.Request) { // Parse mode parameter (defaults to "interactive") @@ -46,38 +59,3 @@ func doConsole(consoleLogsPath string, sessions *interactiveSessions, w http.Res http.Error(w, fmt.Sprintf("Invalid mode parameter: %s (must be 'interactive' or 'tail')", mode), http.StatusBadRequest) } } - -func SetupRoutes(consoleLogsPath string) *chi.Mux { - router := chi.NewRouter() - interactiveSessions := newInteractiveSessions() - - // Add common middleware - router.Use(middleware.RedirectSlashes) - - router.Route(routePrefix, func(r chi.Router) { - // Public routes (no authentication required) - r.Get("/liveness", doLiveness) - r.Get("/readiness", doReadiness) - r.Get("/health", doHealth) - - // Protected routes - add to a sub-router with JWT middleware - r.Group(func(r chi.Router) { - // Conditionally add JWT authentication middleware - if TokenAuth != nil { - r.Use( - jwtauth.Verifier(TokenAuth), - openchami_authenticator.AuthenticatorWithRequiredClaims(TokenAuth, []string{"sub", "iss", "aud"}), - ) - } else { - slog.Warn("JWT authentication is disabled - all console endpoints are unprotected") - } - - r.Get("/consoles", doConsoles) - r.Get("/consoles/{nodeID}", func(w http.ResponseWriter, r *http.Request) { - doConsole(consoleLogsPath, interactiveSessions, w, r) - }) - }) - }) - - return router -} diff --git a/internal/middleware/conditional_middleware_generated.go b/internal/middleware/conditional_middleware_generated.go new file mode 100644 index 0000000..5a248d9 --- /dev/null +++ b/internal/middleware/conditional_middleware_generated.go @@ -0,0 +1,133 @@ +/* + * Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC + * + * SPDX-License-Identifier: MIT + */ + +// Code generated by Fabrica. DO NOT EDIT. +package server + +import ( + "crypto/md5" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "strings" +) + +// ETagAlgorithm defines the hashing algorithm for ETags +// Configured in .fabrica.yaml: sha256 +const ETagAlgorithm = "sha256" // sha256, md5 + +// ConditionalMiddleware handles ETags and conditional requests +// +// Features: +// - Generates ETags for GET responses +// - Validates If-Match headers for PUT/PATCH/DELETE +// - Validates If-None-Match for conditional GET +// - Returns 304 Not Modified when appropriate +// - Returns 412 Precondition Failed on ETag mismatch +func ConditionalMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Set cache control headers + w.Header().Set("Cache-Control", "private, must-revalidate") + + next.ServeHTTP(w, r) + }) +} + +// GenerateETag generates an ETag for the given data +func GenerateETag(data interface{}) (string, error) { + // Marshal to JSON for consistent hashing + jsonData, err := json.Marshal(data) + if err != nil { + return "", fmt.Errorf("failed to marshal data: %w", err) + } + + var hash string + switch ETagAlgorithm { + case "sha256": + h := sha256.Sum256(jsonData) + hash = hex.EncodeToString(h[:]) + case "md5": + h := md5.Sum(jsonData) + hash = hex.EncodeToString(h[:]) + default: + return "", fmt.Errorf("unknown ETag algorithm: %s", ETagAlgorithm) + } + + // Format as W/"" (weak ETag) + return fmt.Sprintf(`W/"%s"`, hash[:16]), nil +} + +// CheckIfMatch validates If-Match header for conditional updates +// +// Returns: +// - true if the condition passes (update should proceed) +// - false if the condition fails (returns 412 Precondition Failed) +func CheckIfMatch(w http.ResponseWriter, r *http.Request, currentETag string) bool { + ifMatch := r.Header.Get("If-Match") + if ifMatch == "" { + // No If-Match header, allow update + return true + } + + // Parse If-Match (can be comma-separated list) + tags := strings.Split(ifMatch, ",") + for _, tag := range tags { + tag = strings.TrimSpace(tag) + if tag == "*" || tag == currentETag { + return true + } + } + + // ETag mismatch - return 412 Precondition Failed + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusPreconditionFailed) + json.NewEncoder(w).Encode(map[string]interface{}{ + "error": "Precondition failed", + "message": "Resource has been modified. Please fetch the latest version and retry.", + "current_etag": currentETag, + "provided_etag": ifMatch, + }) + return false +} + +// CheckIfNoneMatch validates If-None-Match header for conditional GET +// +// Returns: +// - true if the resource should be returned (modified) +// - false if 304 Not Modified should be returned +func CheckIfNoneMatch(w http.ResponseWriter, r *http.Request, currentETag string) bool { + ifNoneMatch := r.Header.Get("If-None-Match") + if ifNoneMatch == "" { + // No If-None-Match header, return resource + return true + } + + // Parse If-None-Match (can be comma-separated list) + tags := strings.Split(ifNoneMatch, ",") + for _, tag := range tags { + tag = strings.TrimSpace(tag) + if tag == "*" || tag == currentETag { + // ETag matches - return 304 Not Modified + w.WriteHeader(http.StatusNotModified) + return false + } + } + + // ETag doesn't match - return resource + return true +} + +// SetETag sets the ETag header on the response +func SetETag(w http.ResponseWriter, etag string) { + w.Header().Set("ETag", etag) +} + +// SetCacheControl sets cache control headers +func SetCacheControl(w http.ResponseWriter, directive string) { + w.Header().Set("Cache-Control", directive) +} diff --git a/internal/middleware/validation_middleware_generated.go b/internal/middleware/validation_middleware_generated.go new file mode 100644 index 0000000..745e261 --- /dev/null +++ b/internal/middleware/validation_middleware_generated.go @@ -0,0 +1,97 @@ +/* + * Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC + * + * SPDX-License-Identifier: MIT + */ + +// Code generated by Fabrica. DO NOT EDIT. +package server + +import ( + "encoding/json" + "log" + "net/http" + + "github.com/openchami/fabrica/pkg/validation" +) + +// ValidationMode defines how validation failures are handled +// Configured in .fabrica.yaml: strict +const ValidationMode = "strict" // strict, warn, disabled + +// ValidationMiddleware validates resources before processing +// +// Validation modes: +// - strict: Return 400 on validation failures (production) +// - warn: Log validation failures but continue (development) +// - disabled: Skip validation entirely (not recommended) +func ValidationMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Skip if disabled + if ValidationMode == "disabled" { + next.ServeHTTP(w, r) + return + } + + // Only validate on POST/PUT/PATCH + if r.Method != http.MethodPost && + r.Method != http.MethodPut && + r.Method != http.MethodPatch { + next.ServeHTTP(w, r) + return + } + + // Validation happens in handlers using ValidateAndRespond() + // This middleware sets up the validation context + next.ServeHTTP(w, r) + }) +} + +// ValidateAndRespond validates a resource and handles the response based on mode +// +// Returns: +// - true if validation passed or mode is warn +// - false if validation failed in strict mode (response already sent) +// +// Usage in handlers: +// +// if !ValidateAndRespond(w, r, resource) { +// return // Response already sent +// } +func ValidateAndRespond(w http.ResponseWriter, r *http.Request, resource interface{}) bool { + if err := validation.ValidateResource(resource); err != nil { + if ValidationMode == "strict" { + // Return 400 Bad Request + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]interface{}{ + "error": "Validation failed", + "details": err.Error(), + }) + return false + } else if ValidationMode == "warn" { + // Log but continue + log.Printf("WARN: Validation failed for %T: %v", resource, err) + return true + } + } + + return true +} + +// ValidationError represents a structured validation error +type ValidationError struct { + Field string `json:"field"` + Message string `json:"message"` +} + +// FormatValidationErrors converts validation errors to structured format +func FormatValidationErrors(err error) []ValidationError { + // This is a simplified version - in practice, parse validator errors + return []ValidationError{ + { + Field: "unknown", + Message: err.Error(), + }, + } +} diff --git a/internal/middleware/versioning_middleware_generated.go b/internal/middleware/versioning_middleware_generated.go new file mode 100644 index 0000000..e503d28 --- /dev/null +++ b/internal/middleware/versioning_middleware_generated.go @@ -0,0 +1,174 @@ +/* + * Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC + * + * SPDX-License-Identifier: MIT + */ + +// Code generated by Fabrica. DO NOT EDIT. +package server + +import ( + "encoding/json" + "fmt" + "net/http" + "regexp" + "strconv" + "strings" + + "github.com/openchami/fabrica/pkg/versioning" +) + +// VersionStrategy defines how API versions are negotiated +// Configured in .fabrica.yaml: header +const VersionStrategy = "header" // header, url, both + +// SupportedVersions lists all API versions supported by this server +var SupportedVersions = []int{1} + +// DefaultVersion is the version used when none is specified +const DefaultVersion = 1 + +// VersioningMiddleware handles API version negotiation +// +// Strategies: +// - header: Uses Accept header (application/vnd.myapp.v1+json) +// - url: Uses URL prefix (/v1/resources) +// - both: Supports both strategies, header takes precedence +// +// Sets request context with: +// - "api-version": int (requested version) +func VersioningMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var version int + var err error + + switch VersionStrategy { + case "header": + version, err = extractVersionFromHeader(r) + case "url": + version, err = extractVersionFromURL(r) + case "both": + // Try header first, fall back to URL + version, err = extractVersionFromHeader(r) + if err != nil || version == 0 { + version, err = extractVersionFromURL(r) + } + default: + version = DefaultVersion + } + + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]interface{}{ + "error": "Invalid API version", + "message": err.Error(), + "supported_versions": SupportedVersions, + }) + return + } + + // Use default if no version specified + if version == 0 { + version = DefaultVersion + } + + // Validate version is supported + if !isVersionSupported(version) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotAcceptable) + json.NewEncoder(w).Encode(map[string]interface{}{ + "error": "Unsupported API version", + "requested_version": version, + "supported_versions": SupportedVersions, + }) + return + } + + // Note: Version context is handled by versioning middleware + // The versioning.VersionNegotiationMiddleware should be used instead + + // Set version header in response + w.Header().Set("X-API-Version", fmt.Sprintf("%d", version)) + + next.ServeHTTP(w, r) + }) +} + +// extractVersionFromHeader parses version from Accept header +// Format: Accept: application/vnd.myapp.v1+json +func extractVersionFromHeader(r *http.Request) (int, error) { + accept := r.Header.Get("Accept") + if accept == "" { + return 0, nil // No version specified + } + + // Parse Accept header for version + // Matches: application/vnd.*.v+json + re := regexp.MustCompile(`application/vnd\.[^.]+\.v(\d+)\+json`) + matches := re.FindStringSubmatch(accept) + + if len(matches) < 2 { + // Try simple application/json (use default) + if strings.Contains(accept, "application/json") { + return 0, nil + } + return 0, fmt.Errorf("invalid Accept header format: %s", accept) + } + + version, err := strconv.Atoi(matches[1]) + if err != nil { + return 0, fmt.Errorf("invalid version number: %s", matches[1]) + } + + return version, nil +} + +// extractVersionFromURL parses version from URL path +// Format: /v1/resources or /api/v1/resources +func extractVersionFromURL(r *http.Request) (int, error) { + path := r.URL.Path + + // Match /v/ or /api/v/ + re := regexp.MustCompile(`^(?:/api)?/v(\d+)/`) + matches := re.FindStringSubmatch(path) + + if len(matches) < 2 { + return 0, nil // No version in URL + } + + version, err := strconv.Atoi(matches[1]) + if err != nil { + return 0, fmt.Errorf("invalid version number in URL: %s", matches[1]) + } + + return version, nil +} + +// isVersionSupported checks if the requested version is supported +func isVersionSupported(version int) bool { + for _, v := range SupportedVersions { + if v == version { + return true + } + } + return false +} + +// GetVersionFromContext retrieves the API version from request context +func GetVersionFromContext(r *http.Request) string { + versionCtx := versioning.GetVersionContext(r.Context()) + if versionCtx != nil { + return versionCtx.ServeVersion + } + return "v1" +} + +// VersionDeprecatedWarning adds deprecation warning header +func VersionDeprecatedWarning(w http.ResponseWriter, version int, sunsetDate string) { + w.Header().Set("X-API-Deprecation", fmt.Sprintf("Version %d is deprecated", version)) + if sunsetDate != "" { + w.Header().Set("Sunset", sunsetDate) + } + w.Header().Set("Link", `; rel="deprecation"`) +} diff --git a/pkg/apiversion/registry_generated.go b/pkg/apiversion/registry_generated.go new file mode 100644 index 0000000..1d7d1d6 --- /dev/null +++ b/pkg/apiversion/registry_generated.go @@ -0,0 +1,39 @@ +// Code generated by Fabrica dev. DO NOT EDIT. +// Template: apiversion/register.gotmpl +// Generated at: 2026-07-09T20:29:43Z +// SPDX-FileCopyrightText: Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC +// SPDX-License-Identifier: MIT +// +// + +package apiversion + +import ( + "github.com/openchami/fabrica/pkg/apiversion" + "github.com/openchami/fabrica/pkg/versioning" +) + +// Registry is the global API version registry +var Registry *apiversion.Registry + +func init() { + Registry = apiversion.NewRegistry() + Registry.RegisterGroup(apiversion.Group{ + Name: "remote-console.openchami.io", + StorageVersion: "v1", + Spokes: []string{"v1"}, + }) + if err := versioning.GlobalVersionRegistry.RegisterVersion("Console", "v1", versioning.ResourceTypeInfo{ + Metadata: versioning.SchemaVersion{ + Version: "v1", + IsDefault: true, + }, + }); err != nil { + panic(err) + } +} + +// GetRegistry returns the global version registry +func GetRegistry() *apiversion.Registry { + return Registry +} diff --git a/pkg/client/models_generated.go b/pkg/client/models_generated.go new file mode 100644 index 0000000..9c3f0ed --- /dev/null +++ b/pkg/client/models_generated.go @@ -0,0 +1,64 @@ +// Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC +// +// SPDX-License-Identifier: MIT +// +// This file contains client-side request/response models. +// Generated from: pkg/codegen/templates/client-models.go.tmpl +// +// To modify client models: +// 1. Edit the template file: pkg/codegen/templates/client-models.go.tmpl +// 2. Run 'fabrica generate' to regenerate +// 3. Do NOT edit this file directly - changes will be lost +// +// DEPRECATED: This template is a legacy stub. The actual client models are +// generated in models.go.tmpl and used by both server and client. +// +// Generated request types: +// - CreateResourceRequest: Create operation with metadata +// - UpdateResourceRequest: Update operation with partial fields +// - DeleteResponse: Confirmation of deletion +// +// Note: This file contains TODO comments indicating that resource-specific +// fields should be added. Consider migrating to the unified models.go.tmpl +// approach or implementing spec field embedding. +// +// To implement full spec support: +// 1. Import resource packages +// 2. Embed ResourceSpec in request structs +// 3. Use json:",inline" tag for proper serialization +// +// To add validation: +// 1. Add validate tags (e.g., validate:"required,min=3") +// 2. Use go-playground/validator in client before sending +// + +// Package client provides generated client-side request and response models. +package client + +import ( + "github.com/openchami/fabrica/pkg/fabrica" + + "github.com/OpenCHAMI/remote-console/apis/remote-console.openchami.io/v1" +) + +// CreateConsoleRequest represents a request to create a Console +type CreateConsoleRequest struct { + Metadata fabrica.Metadata `json:"metadata" yaml:"metadata" validate:"required"` + Spec v1.ConsoleSpec `json:"spec" yaml:"spec" validate:"required"` + Labels map[string]string `json:"labels,omitempty" yaml:"labels,omitempty"` + Annotations map[string]string `json:"annotations,omitempty" yaml:"annotations,omitempty"` +} + +// UpdateConsoleRequest represents a request to update a Console +type UpdateConsoleRequest struct { + Metadata fabrica.Metadata `json:"metadata,omitempty" yaml:"metadata,omitempty"` + Spec v1.ConsoleSpec `json:"spec,omitempty" yaml:"spec,omitempty" validate:"omitempty"` + Labels map[string]string `json:"labels,omitempty" yaml:"labels,omitempty"` + Annotations map[string]string `json:"annotations,omitempty" yaml:"annotations,omitempty"` +} + +// DeleteResponse represents a successful deletion response +type DeleteResponse struct { + Message string `json:"message"` + UID string `json:"uid"` +} diff --git a/pkg/resources/register_generated.go b/pkg/resources/register_generated.go new file mode 100644 index 0000000..bf9d13b --- /dev/null +++ b/pkg/resources/register_generated.go @@ -0,0 +1,30 @@ +// Code generated by Fabrica dev. DO NOT EDIT. +// Template: cmd/fabrica/generate.go:generateVersionedRegistrationCode +// Generated: 2026-07-13T14:10:55Z +// +// Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC +// +// SPDX-License-Identifier: MIT + +package resources + +import ( + "fmt" + + v1 "github.com/OpenCHAMI/remote-console/apis/remote-console.openchami.io/v1" + "github.com/openchami/fabrica/pkg/codegen" +) + +// RegisterAllResources registers all discovered resources with the generator. +// This file is auto-generated. Re-run 'fabrica generate' after adding resources. +func RegisterAllResources(gen *codegen.Generator) error { + if err := gen.RegisterResource(&v1.Console{}); err != nil { + return fmt.Errorf("failed to register Console: %w", err) + } + gen.ConfigureResource("Console", codegen.ResourceConfig{ + URLPath: "/remote-console/consoles", + Operations: codegen.ResourceOperationsFromNames([]string{"list"}), + }) + + return nil +} diff --git a/test/containers.go b/test/containers.go index 63a3405..65dad93 100644 --- a/test/containers.go +++ b/test/containers.go @@ -493,6 +493,7 @@ func startRemoteConsoleWithEnv(ctx context.Context, envOverrides map[string]stri }, }, Env: map[string]string{ + "REMOTE_CONSOLE_AUTH_ENABLED": "false", "RCS_SMD_URL": "http://smd:27779", "SMS_SERVER": "http://smd:27779", "CRAY_VAULT_AUTH_PATH": "auth/token/create", @@ -518,10 +519,10 @@ func startRemoteConsoleWithEnv(ctx context.Context, envOverrides map[string]stri "RCS_LOG_ROTATE_STATE_FILE_PATH": "/tmp/rot_conman.state", }, ExposedPorts: []string{"26776/tcp"}, - WaitingFor: wait.ForHTTP("/remote-console/readiness"). + WaitingFor: wait.ForHTTP("/health"). WithPort("26776/tcp"). WithStatusCodeMatcher(func(status int) bool { - return status == http.StatusNoContent + return status == http.StatusOK }). WithStartupTimeout(120 * time.Second), } diff --git a/test/integration_test.go b/test/integration_test.go index 8061547..60fe92d 100644 --- a/test/integration_test.go +++ b/test/integration_test.go @@ -29,7 +29,7 @@ import ( "github.com/testcontainers/testcontainers-go/network" "golang.org/x/crypto/ssh" - "github.com/OpenCHAMI/remote-console/internal/console" + v1 "github.com/OpenCHAMI/remote-console/apis/remote-console.openchami.io/v1" "github.com/OpenCHAMI/remote-console/internal/nodes" ) @@ -278,9 +278,7 @@ func (s *IntegrationTestSuite) TearDownSuite() { } func (s *IntegrationTestSuite) TestHealthCheck() { - var healthResponse console.HealthResponse - - resp, err := http.Get(s.apiURL + "/remote-console/health") + resp, err := http.Get(s.apiURL + "/health") s.Require().NoError(err) defer func() { if err := resp.Body.Close(); err != nil { @@ -289,31 +287,11 @@ func (s *IntegrationTestSuite) TestHealthCheck() { }() s.Equal(http.StatusOK, resp.StatusCode) + var healthResponse map[string]string err = json.NewDecoder(resp.Body).Decode(&healthResponse) s.Require().NoError(err) - s.Equal("5", healthResponse.NumberConsoles) -} - -func (s *IntegrationTestSuite) TestReadinessCheck() { - resp, err := http.Get(s.apiURL + "/remote-console/readiness") - s.Require().NoError(err) - defer func() { - if err := resp.Body.Close(); err != nil { - s.T().Logf("Warning: failed to close response body: %v", err) - } - }() - s.Equal(http.StatusNoContent, resp.StatusCode) -} - -func (s *IntegrationTestSuite) TestLivenessCheck() { - resp, err := http.Get(s.apiURL + "/remote-console/liveness") - s.Require().NoError(err) - defer func() { - if err := resp.Body.Close(); err != nil { - s.T().Logf("Warning: failed to close response body: %v", err) - } - }() - s.Equal(http.StatusNoContent, resp.StatusCode) + s.Equal("healthy", healthResponse["status"]) + s.Equal("remote-console", healthResponse["service"]) } // sortByID sorts a slice of any type that has an ID field @@ -323,6 +301,29 @@ func sortByID[T any](slice []T, getID func(T) string) { }) } +func decodeConsoleResources(body io.Reader) ([]nodes.NodeConsoleInfo, error) { + var resources []v1.Console + if err := json.NewDecoder(body).Decode(&resources); err != nil { + return nil, err + } + + consoles := make([]nodes.NodeConsoleInfo, 0, len(resources)) + for _, resource := range resources { + id := resource.Metadata.Name + if id == "" { + id = resource.Metadata.UID + } + consoles = append(consoles, nodes.NodeConsoleInfo{ + ID: id, + ConnectionType: resource.Spec.ConnectionType, + ConnectionHost: resource.Spec.ConnectionHost, + ConnectionPort: resource.Spec.ConnectionPort, + ConsoleEntryCommand: resource.Spec.ConsoleEntryCommand, + }) + } + return consoles, nil +} + // TestConsoleNodesDiscovered verifies nodes are discovered from SMD func (s *IntegrationTestSuite) TestConsoles() { resp, err := http.Get(s.apiURL + "/remote-console/consoles") @@ -334,19 +335,18 @@ func (s *IntegrationTestSuite) TestConsoles() { }() s.Equal(http.StatusOK, resp.StatusCode) - var consolesResponse console.ConsolesResponse - err = json.NewDecoder(resp.Body).Decode(&consolesResponse) + consoles, err := decodeConsoleResources(resp.Body) s.Require().NoError(err) // 2 SSH password nodes, 2 SSH key nodes, 1 IPMI node - s.Require().Equal(len(consolesResponse.Consoles), 5, "Expected 5 consoles") + s.Require().Equal(5, len(consoles), "Expected 5 consoles") sshPasswordFixture := consoleFixtures["ssh-password"] sshKeyFixture := consoleFixtures["ssh-key"] ipmiFixture := consoleFixtures["ipmi"] entryCmd := "echo 'Hello n0' && /bin/sh" - consoles := []nodes.NodeConsoleInfo{ + expectedConsoles := []nodes.NodeConsoleInfo{ { ID: sshPasswordFixture.nodeID, ConnectionType: "ssh", @@ -382,10 +382,10 @@ func (s *IntegrationTestSuite) TestConsoles() { } // Sort both slices for comparison - sortByID(consolesResponse.Consoles, func(n nodes.NodeConsoleInfo) string { return n.ID }) sortByID(consoles, func(n nodes.NodeConsoleInfo) string { return n.ID }) + sortByID(expectedConsoles, func(n nodes.NodeConsoleInfo) string { return n.ID }) - s.Equal(consoles, consolesResponse.Consoles, "Consoles do not match expected consoles") + s.Equal(expectedConsoles, consoles, "Consoles do not match expected consoles") } @@ -569,9 +569,8 @@ func (s *IntegrationTestSuite) waitForConsoles(expected int, timeout time.Durati for time.Now().Before(deadline) { resp, err := http.Get(s.apiURL + "/remote-console/consoles") if err == nil { - var consolesResp console.ConsolesResponse - if decodeErr := json.NewDecoder(resp.Body).Decode(&consolesResp); decodeErr == nil { - if len(consolesResp.Consoles) == expected { + if consoles, decodeErr := decodeConsoleResources(resp.Body); decodeErr == nil { + if len(consoles) == expected { if err := resp.Body.Close(); err != nil { s.T().Logf("Warning: failed to close response body: %v", err) } @@ -594,9 +593,8 @@ func (s *IntegrationTestSuite) waitForConsoleID(nodeID string, timeout time.Dura for time.Now().Before(deadline) { resp, err := http.Get(s.apiURL + "/remote-console/consoles") if err == nil { - var consolesResp console.ConsolesResponse - if decodeErr := json.NewDecoder(resp.Body).Decode(&consolesResp); decodeErr == nil { - for _, consoleInfo := range consolesResp.Consoles { + if consoles, decodeErr := decodeConsoleResources(resp.Body); decodeErr == nil { + for _, consoleInfo := range consoles { if consoleInfo.ID == nodeID { if err := resp.Body.Close(); err != nil { s.T().Logf("Warning: failed to close response body: %v", err) @@ -620,11 +618,10 @@ func (s *IntegrationTestSuite) waitForConsoleRemoval(nodeID string, timeout time for time.Now().Before(deadline) { resp, err := http.Get(s.apiURL + "/remote-console/consoles") if err == nil { - var consolesResp console.ConsolesResponse - if decodeErr := json.NewDecoder(resp.Body).Decode(&consolesResp); decodeErr == nil { + if consoles, decodeErr := decodeConsoleResources(resp.Body); decodeErr == nil { found := false - for _, consoleInfo := range consolesResp.Consoles { + for _, consoleInfo := range consoles { if consoleInfo.ID == nodeID { found = true break