Skip to content

Latest commit

 

History

History
181 lines (127 loc) · 15 KB

ARCHITECTURE.md

File metadata and controls

181 lines (127 loc) · 15 KB

Architecture — func-console

Stack

React + TypeScript + PatternFly 6 + OCP Dynamic Plugin SDK

Layered Architecture

flowchart TB
    TYPES[Types] ---|cross-cutting| UTILS[Utils]
    CLIENTS[Clients] ---|cross-cutting| UTILS
    COMPONENTS[Components] ---|cross-cutting| UTILS
    PAGES[Pages] --> COMPONENTS[Components]
    PAGES --> HOOKS[Hooks]
    COMPONENTS --> HOOKS
    COMPONENTS --> TYPES
    HOOKS --> CLIENTS[Clients]
    HOOKS --> TYPES
    CLIENTS --> TYPES[Types]
Loading

Arrows mean "imports / depends on."

Layer Maps to Depends on
Types common/types.ts, pages/<name>/types.ts nothing
Clients common/clients/ (plain async functions) Types, Utils
Hooks Inlined in page/component files. Extract to pages/<name>/hooks/ or common/hooks/ only when reused. Clients, Types, Utils
Components common/components/ (shared), pages/<name>/components/ (page-specific) Hooks, Types, Utils
Pages pages/<name>/ Components, Hooks, Utils, Types
Utils common/utils/ nothing (cross-cutting)

Dependency Rules

  • Unidirectional: Types <- Clients <- Hooks <- Components <- Pages
  • Utils can be imported by any layer
  • Pages and components may import clients and hooks directly
  • Clients/hooks never import Components or Pages
  • No circular dependencies

Co-location Convention

  • src/pages/<name>/ contains the page component, its test, and a components/ subdir
  • src/pages/<name>/components/ contains components used only by that page
  • src/common/ contains everything shared across pages (components, clients, utils, context)
  • src/common/types.ts contains shared wire/DTO types (backend response shapes) and pure functions that derive from those types. Used across pages. No React, no side effects, no imports from components or hooks.
  • src/pages/<name>/types.ts contains types and pure functions that derive from those types. Used only within that page's directory. No React, no side effects, no imports from components or hooks.
  • Ownership rule: pages are divided into sections which are extracted into components in pages/<name>/components/. Components that have sub-components keep them in the same file unless they can be reused, in which case they are extracted to their own file. Components shared across multiple pages live in common/components/.

React

Pages

  • Smart for page-specific data -- pages use clients in their page hooks to fetch, prepare, and transform all data needed for downstream components. If multiple pages need the same data, extract a shared hook into common/hooks/.

Components

  • Simple by default — they receive data via props, render it, and call callbacks. No logic at the top of a component.
  • May own data when self-contained -- a component may own its own data and state when it encapsulates a self-contained capability that is not specific to any one page (e.g., auth flows, notification subscriptions). Such components may use hooks directly. The component becomes the single owner of that concern. Pages consume it without orchestrating its internals.
  • Sub-components — if a sub-component is only used by one parent, keep it in the parent's file, unexported. Extract to its own file only when the sub-component is used by multiple siblings.

Clients

common/clients/ contains thin wrappers around external APIs:

  • Plain async functions (e.g., functionsClient.ts) -- stateless fetch wrappers and SSE stream consumers that page hooks call.

Hooks

  • Extract logic into hooks — if a page or component has non-trivial logic (state management, data transformation, side effects), extract it into a custom hook. One-liner logic stays inlined in the component, no hook needed. If the hook is only used by one component, keep it in the same file, do not export it. If the hook is reused by multiple components within one page, put it in src/pages/<name>/hooks/. If reused across pages, put it in src/common/hooks/. Keep the translation hook (useTranslation) at the top of the page component, not inside custom hooks.

Utility Functions

  • Same co-location rule as hooks — if a utility function is only used by one component or hook, keep it in the same file, do not export it. If it is reused by multiple files within one page, put it in src/pages/<name>/utils/. If reused across pages, put it in src/common/utils/.

File Ordering

Within a file, put the exported component at the top, then its hook below, then sub-components, then helper functions at the bottom. Readers see the main thing first and can drill down.

Performance

  • No speculative memoization: Do not wrap every function in useCallback or every value in useMemo as a habit. Use them when there is a concrete reason: a React.memo child that depends on a stable reference, or a known re-render path (e.g., a sibling component re-rendering on every keystroke). Plain functions and derived values are the default.

Architectural Guidance

  • PatternFly components preferred over custom HTML
  • PatternFly styling and styling rules over custom CSS
  • Error handling through ErrorProvider/addError pattern
  • Shared utilities in common/utils/, not hand-rolled per component
  • Clients consumed through hooks, never imported directly

Backend (Go)

Stack

Go + net/http standard library. Key dependencies:

Dependency Role
k8s.io/client-go Kubernetes API client (SA, RBAC, TokenRequest, kubeconfig)
google/go-github/v90 GitHub API client
knative.dev/func Function scaffold generation
onsi/ginkgo + onsi/gomega Test framework

Packages

Package Responsibility
kube Shared Kubernetes connection: builds a *rest.Config from host/token/caCert (or in-cluster), including the JSON content config and the default request timeout
cluster Kubernetes provisioning: service account, RBAC provisioning, TokenRequest, kubeconfig generation; per-namespace existence checks
functions Function lifecycle via knative/func: cluster queries behind the growable functions.Client facade (lists today), plus source/CI scaffold generation (Generate)
handler HTTP handlers: input validation, orchestration, error mapping
scm SCM abstraction types (Platform, Registry, Client) and filesystem helpers
scm/github go-github implementation of scm.Client
config Runtime configuration, package-level wiring vars (SCMRegistry), constants, and service account token expiry parsing
tlsreload Reloads the serving cert/key from disk on change (fsnotify plus a poll fallback), swapping an atomic *tls.Certificate via GetCertificate so rotated certs are served without a restart

Dependency Rules

  • handler imports cluster, functions, scm, config — never the reverse
  • cluster and functions both import kube for connection setup, and have no knowledge of each other
  • kube imports only k8s.io/client-go/rest; it depends on no other backend package
  • cluster is for provisioning and cluster introspection (write RBAC/SA, request tokens, check namespace existence); functions is for the function lifecycle (list and scaffold generation). Both talk to the cluster but answer different questions, so they stay separate rather than sharing one client interface
  • scm has no knowledge of cluster or functions
  • functions imports scm only for scm.Platform and scm.FileEntry types
  • config is imported by handler, functions, and main only; it owns runtime configuration and package-level wiring

Key Decisions

Cluster access is split by intent: cluster (provisioning and introspection) vs functions (function lifecycle) Both talk to the same API server but answer different questions, so they are separate packages rather than one god-client. cluster writes RBAC/service accounts, mints tokens, and checks namespace existence for repo function validation; functions owns the function lifecycle via knative/func. The shared connection logic lives in kube.RESTConfig, which both call, so host resolution, TLS, JSON content config, and the default request timeout are defined once. kube is a leaf (depends only on client-go/rest), which keeps it importable by any domain package without cycles — unlike config, the wiring layer, which is off-limits to domain packages.

functions is the single knative/func facade, but splits offline scaffolding from cluster operations Everything that wraps knative.dev/func lives in this one package, so there is a single boundary around that dependency. Within it, two responsibilities are kept apart because they have different needs:

  • Generate (scaffold.go) is offline: it scaffolds a new function's source and CI files into a temp dir and returns scm.FileEntry blobs to push to a repo. It never contacts the cluster and needs no REST config, so it is a plain package function, not a method on Client. It is called standalone from the create handler, before any cluster client exists.
  • functions.Client (client.go) is the cluster-connected CRUD facade, built via NewClient(host, token, caCert) and holding a *rest.Config. It exposes List today; deploy/undeploy/describe are expected next, hence an interface rather than a bare Lister.

Generate is deliberately not folded into Client: it would ignore the receiver's config entirely, and callers that only scaffold (the create handler) would be forced to build a cluster client with a host and tokens they do not need. Scaffolding produces repo source, not a cluster resource; the cluster-level "create" (deploy) will land on Client when it arrives.

Cluster host resolution: explicit parameter via --kube-host flag cluster.New(host, token, caCert) and functions.NewClient(...) accept the API server URL as an explicit parameter and pass it to kube.RESTConfig. Empty host triggers rest.InClusterConfig() (production pods). In dev, hack/dev.sh passes --kube-host $KUBE_API_SERVER to the backend binary; in tests, it is passed directly. Env var injection (KUBERNETES_SERVICE_HOST) was explicitly rejected as it abuses a Kubernetes-standardized variable and creates hidden ambient state.

External API URL resolved at Helm install time The URL embedded in generated kubeconfigs (externalAPIServerURL) comes from the Infrastructure CR (config.openshift.io/v1/Infrastructure/cluster) via Helm lookup at install time, injected as --external-api-server-url. It is not fetched at runtime. This eliminates the need for a ClusterRole to query the Infrastructure CR from within the pod.

Deployment credentials are short-lived and refreshed before file updates The backend requests service account tokens with a configurable lifetime, set by --sa-token-expiry and defaulting to seven days. When a function is created, its kubeconfig is stored as the KUBECONFIG SCM secret and the token expiration timestamp is stored as the KUBECONFIG_EXPIRE_AT SCM variable. Before pushing edited files, the handler refreshes both values when the token has 24 hours or less remaining. Missing or malformed expiration metadata also triggers a refresh. Refresh uses the caller's OCP bearer token and the namespace from the repository's func.yaml; no cluster credentials are retained by the backend.

TLS serving certificate reloaded at runtime by an fsnotify + poll hybrid The OCP service CA operator rotates the serving cert/key automatically. tlsreload.Reloader watches the mounted pair with fsnotify and atomically swaps the cached *tls.Certificate served via tls.Config.GetCertificate, so a rotation is picked up without restarting the pod. A poll ticker running every 30 seconds operates alongside the watcher as a safety net for events fsnotify can miss, in particular the atomic ..data symlink swap Kubernetes uses for mounted secrets: when a watched file is removed or renamed the watch is re-added to the new file, and the poll guarantees the change is eventually observed regardless. Polling stays active during watcher setup failures and the 3-second watcher restart delays. Watcher events and poll ticks are processed on one goroutine, so reload and its content hash remain serialized and the swap needs only a plain atomic Store (no mutex). fsnotify was promoted from an existing indirect dependency; the heavier k8s.io/apiserver/dynamiccertificates and controller-runtime/certwatcher (which pulls in Prometheus) were avoided. Reloads are content-hashed to skip re-parsing when the pair is unchanged, and the last valid pair is retained when an update is incomplete or invalid.

SCM is abstracted behind a registry scm.Registry maps scm.Platform → scm.ClientFactory. The active registry lives at config.SCMRegistry, a package-level var that tests swap out via withSCMMock. Handlers never reference a concrete SCM client type. The platform is currently resolved statically (scm.DefaultPlatform = GitHub), but the registry is designed to support dynamic platform selection — the handler can later derive the platform from the request body or header without changes to the registry or client implementations.

Repo function namespace validation uses Get per namespace, not List all namespaces When listRepoFunctions collects repo-backed functions, it validates each function's namespace against the cluster using cluster.Client.CheckNamespace, which calls CoreV1().Namespaces().Get(ctx, namespace, ...). Three outcomes are possible:

  • 404 Not Found (cluster.ErrNamespaceNotFound): namespace is confirmed absent. The function is shown with err: "func.yaml: namespace does not exist".
  • 403 Forbidden (cluster.ErrNamespaceForbidden): namespace exists but the caller's OCP token cannot access it. The function is shown with err: "func.yaml: namespace access denied" so the user can see it and request access.
  • Other error (transient network or API failure): the function is shown with err: "func.yaml: namespace check failed" rather than being silently hidden or shown as NotDeployed.

Using List all namespaces and then checking membership was rejected: Namespaces().List(...) returns only namespaces visible to the caller's token, and regular OCP users typically lack the cluster-wide list verb. Get per namespace is used instead: it requires only the get verb scoped to the target namespace, and disambiguates 404 (genuinely absent) from 403 (exists, no access) — a distinction that List cannot make.

Handler error mapping createFunction wraps upstream failures explicitly:

Error HTTP status
scm.ErrUnauthorized 401
scm.ErrRepoExists 409
errUpstream (cluster or SCM failure) 502
validation failure 400
internal error 500

Handlers are stateless Handlers holds only static configuration, including the requested service account token lifetime. Cluster clients are request-scoped, authenticated with the caller's OCP bearer token, and created only when an operation needs cluster access. There is no shared connection, credential, or session.