funops <resource> <action> [arguments] [flags]
FUN-8 funops - Operator CLI
1. Introduction
This FUN introduces funops, a command-line interface tool for Fundament platform operators. The tool provides direct database access for administrative tasks that require root-level system access, such as bootstrapping new organizations or performing system recovery operations.
2. Why funops
2.1. The Need for Operator Tooling
Fundament exposes APIs for end-users (used by Console UI, Terraform provider), but certain administrative operations require elevated privileges beyond what tenant-scoped APIs provide:
-
Bootstrap operations: Creating the initial organization before any users exist
-
System recovery: Fixing data inconsistencies or recovering from failures
-
Administrative tasks: Operations that span multiple organizations or require system-level access
-
Debugging: Inspecting system state during development or incident response
2.2. Direct Database Access
For the initial proof-of-concept, funops operates directly against the PostgreSQL database rather than through the existing APIs. This approach provides:
-
Simplicity: No additional API surface to design and maintain
-
Full access: No RLS restrictions when using a privileged database user
-
Speed of development: Faster iteration for PoC needs
This is not necessarily a permanent decision. Future versions may transition to API-based operations where appropriate, particularly for operations that benefit from audit logging or can be safely exposed to less privileged operators.
3. Design
3.1. CLI Philosophy
funops follows conventions established by similar cloud-native CLI tools:
| Tool | Pattern |
|---|---|
kubectl |
|
metalctl |
|
gardenctl |
|
funops adopts the resource-centric pattern:
Examples:
funops organization create acme-corp --node-quota eu-west-1/n1-standard-2=3 funops organization create acme-corp --node-quota local/local-small=3 --output json funops organization member add acme-corp alice@acme-corp.com --permission admin funops organization member add acme-corp bart@acme-corp.com --create-user funops organization member list acme-corp funops user create cindy@acme-corp.com funops user list --without-organization
3.2. Users and Organizations
Signing in never creates an organization (FUN-4). Getting a user into one is an operator task, and funops covers both orders it can happen in:
-
After the first sign-in:
funops user list --without-organizationshows who is waiting, andfunops organization member add <organization> <user-id|email>assigns them. The membership is accepted right away; an operator assigning someone is not an invitation to answer. The user’s next token refresh picks it up, so no new sign-in is needed. -
Before the first sign-in:
funops organization member add <organization> <email> --create-userregisters the user by email (asfunops user create <email>does on its own) and assigns the membership to that registration, in one transaction. The authn-api links the registration to the identity provider account on the first sign-in at that address, matched case-insensitively. Without--create-user, an address nobody has signed in with is an error: creating accounts as a side effect would turn a mistyped address into a membership nobody can ever claim.
Adding someone who holds an unanswered invitation accepts it on their behalf, in the same statement that would otherwise insert the membership; the invitation keeps the permission it was sent with unless --permission is given, and a new membership defaults to viewer. An invitation is not membership: funops user list shows it separately, and --without-organization still lists the invited person as waiting.
Users are addressed by ID or by email, never by name: names are what the identity provider reports and are not unique. funops organization member remove revokes a membership or a pending invitation the way the organization-api does, and the authz outbox takes the change to OpenFGA. funops user delete soft-deletes a registration nobody has signed in with, together with its memberships; an account somebody has signed in to is refused. funops organization delete soft-deletes an organization and revokes its memberships and API keys in the same transaction; it is refused while the organization has live clusters or publishes plugins.
3.3. Quotas
What an organization may build is capped by quotas the operator sets with funops organization quota: how many clusters it may have (quota_clusters on the organization, one for a new organization) and how many nodes of each machine type it may have per region (tenant.organization_machine_quotas, keyed by the catalog offering; no row means zero). The database enforces both with constraint triggers on tenant.clusters and tenant.node_pools, so every path that writes those rows is covered. Every node a cluster runs comes from one of its node pools: the cluster-worker builds no Shoot for a cluster without pools and never updates a Shoot to zero workers, so nothing new runs outside the quota. A cluster that already ran on the old built-in default worker group before pools were required keeps it until it gets a pool (its sync fails meanwhile), and stays outside the quota until then. funops organization create takes optional starting node quotas (--node-quota <region>/<machine-type>=<max>, set in the same transaction as the organization): an organization without one can build no pool, so its clusters do not start until an operator sets a quota with funops organization quota set-nodes. The node quota caps the sum of the autoscale maxima of the organization’s pools of that offering, across its clusters.
A quota only ever stops something new: a cluster, a pool, or a pool growing. Lowering it below what is in use is allowed and leaves the existing rows alone, so an operator can tighten a quota without interrupting a tenant; quota get-clusters and quota list-nodes show the excess. Deleted clusters and their pools count until Gardener confirms the shoot is gone, as organization delete counts them.
3.4. Non-Interactive Design
funops is designed to be non-interactive and scriptable:
-
All input comes from command-line arguments and flags
-
No interactive prompts or manifest files
-
Suitable for automation, CI/CD pipelines, and scripting
-
Clear exit codes for success/failure
This differs from kubectl’s declarative manifest approach. Fundament operators use imperative commands for administrative tasks rather than applying YAML files.
3.5. Output Formats
funops supports multiple output formats via the --output / -o flag:
| Format | Description |
|---|---|
|
Human-readable tabular format for terminal use |
|
Machine-readable JSON for scripting and automation |
3.6. Configuration
funops uses environment variables for configuration, following the 12-factor app methodology:
| Variable | Required | Description |
|---|---|---|
|
Yes |
PostgreSQL connection string (e.g., |
No configuration files are used. This keeps the tool simple and explicit about its dependencies.
3.7. Database Access
funops connects directly to the Fundament PostgreSQL database. The database user specified in DATABASE_URL should have appropriate permissions:
-
For full operator access: Use a PostgreSQL user that has explicit RLS policies granting full access, or a superuser role
-
RLS policies in the Fundament schema can be configured to allow specific users full access, or the bypassrls attribute
Initial development uses the superuser role and bypassrls attributes.
3.8. Logging
funops uses structured logging via Go’s slog package, with output directed to stderr:
-
Default level: INFO - Shows user feedback for operations that would otherwise be silent (e.g., successful deletes)
-
Debug level (
--debug) - Includes detailed internal operations like database connections and query parameters
This design keeps stdout clean for programmatic use (piping, scripting) while providing appropriate feedback to operators. Commands that produce data output (list, create) write to stdout; informational messages go to stderr via the logger.