Can internal developer platforms and imperative operational tools coexist? I believe they can, if one builds on the right model.
I have recently been writing about building tools to perform Kubernetes management tasks (1, 2, 3). Performing tasks with imperative tools can be more goal-oriented, direct, concise, and ergonomic than editing large, complex configuration-as-code files that must accommodate all conceivable tasks, and can help perform common tasks quickly, correctly, and safely. Imperative tools can also be learned quickly by both humans and AI agents, without reading pages of reference documentation, especially if they have intuitive command names and decent help text. They can also be relatively efficient in terms of token usage, and well scoped, hierarchical commands naturally provide progressive disclosure. Autocompletion is supported in cases where humans are still typing the commands.
I also believe that such tools capture expertise more effectively and in a more reusable form than configuration-as-code templates and modules. For some of the tools, a fair bit of the creation time was spent on “product” research and product definition, and I’m already very familiar with Kubernetes and its ecosystem.
What makes such tools possible is storing configuration as data in a database with an API, as opposed to configuration as code. This enables us to build imperative tools, like, say, kubectl. Tools that can imperatively change the configuration data. A lot of functionality in kubectl, such as kubectl edit, is relatively generic. But tools can be as general or as specific, such as the tools for managing Kubernetes RBAC and NetworkPolicy, as we like.
As with Kubernetes itself, the configuration data stored in ConfigHub is (primarily, for now, other than application configuration) Kubernetes resources, or rather KRM resources, which are declarative. Using controllers like Crossplane, ACK, and Config Connector, KRM resources can also provision cloud resources.
This serialized representation of the desired state can be changed and validated prior to being applied. A GitOps tool, such as ArgoCD or Flux, can perform the apply by pulling the configuration from ConfigHub’s OCI endpoint.
Unlike resources stored by the Kubernetes control plane, the configuration data in ConfigHub is automatically versioned. ConfigHub also supports approaches to keep multiple variants of a configuration in sync. These are the two main capabilities that would otherwise normally drive users to configuration as code tools. Instead, we have a declarative system of record that we can operate upon with tools in a repeatable, scalable manner.
As another tool-building demonstration, I created a tool to manage EKS clusters inspired by eksctl. eksctl provides a simpler experience for creating EKS clusters compared to, say, Terraform or OpenTofu, with developer-friendly imperative commands. To use it, one doesn’t need to know all of the details of the EKS APIs. For example, you can create a cluster with eksctl create cluster.
Under the hood, eksctl generates CloudFormation. Instead of generating CloudFormation configuration, cub-eks creates, updates, reads, and validates Crossplane managed resources in ConfigHub. The code is here:
https://github.com/confighub/examples/tree/main/eks-manager
Why managed resources rather than composite resources? Like configuration as code, Crossplane compositions abstract the underlying managed resources, and are implemented using unidirectional configuration generators. Crossplane compositions expect that all changes (with a few special exceptions) to the generated resources will be made by changing the composition input values or the composition functions. This is called exclusive actuation (similar to template catalogs), and obstructs composable, interoperable tools that read-modify-write the resources, which is what we’re trying to enable. Any out-of-band changes are considered configuration drift. Furthermore, I typically see additional generators, such as Helm and/or Backstage scaffolder templates, layered on top of composite resources, which makes the managed resources even harder to change when operationally necessary.
That doesn’t mean the tools can’t provide opinionated experiences over the underlying resources. For example, I decided to only support EKS auto mode. But it is a golden path, or part of one, as opposed to an inflexible constraint.
Hard and soft guardrails are still possible to enforce, via Triggers. This command:
% cub-eks guardrails install
creates a number of default guardrails to catch changes that would violate policies. A guardrail was also added regarding auto mode, at least for now, but it’s just a warning. You can see what it created by listing the Triggers and what they check (expressed using CEL):
% cub trigger list --space eks-policy -o yq='[.[] | {(.Trigger.Slug): .Trigger.Arguments[0].Value}]'
- eks-automode-invariant: '!(r.kind == ''Cluster'' && has(r.spec.forProvider.computeConfig)) || (has(r.spec.forProvider.storageConfig) && has(r.spec.forProvider.kubernetesNetworkConfig))'
- eks-control-plane-logging: '!(r.kind == ''Cluster'') || (has(r.spec.forProvider.enabledClusterLogTypes) && ''audit'' in r.spec.forProvider.enabledClusterLogTypes)'
- eks-no-latinit: '!(r.kind == ''NodeGroup'' && has(r.spec.managementPolicies)) || !(''LateInitialize'' in r.spec.managementPolicies)'
- eks-pinned-version-extended-support: '!(r.kind == ''Cluster'' && has(r.spec.forProvider.version)) || (has(r.spec.forProvider.upgradePolicy) && r.spec.forProvider.upgradePolicy.supportType == ''EXTENDED'')'
- eks-private-endpoint: '!(r.kind == ''Cluster'' && has(r.spec.forProvider.vpcConfig) && has(r.spec.forProvider.vpcConfig.endpointPublicAccess) && r.spec.forProvider.vpcConfig.endpointPublicAccess) || (has(r.spec.forProvider.vpcConfig.publicAccessCidrs) && !(''0.0.0.0/0'' in r.spec.forProvider.vpcConfig.publicAccessCidrs))'
- eks-secrets-encryption: '!(r.kind == ''Cluster'') || has(r.spec.forProvider.encryptionConfig)'
We can also check where they are registered and whether they have surfaced any gates or warnings:
% cub-eks guardrails status -o table
SPACE UNIT GATES WARNINGS
eks-prod-usw2 cluster 0 3
eks-staging-use1 cluster 0 1
(The output is JSON by default for AI agents.)
We can discover those and some additional issues are with cub-eks findings:
% cub-eks findings -o table
SEVERITY ANALYZER CLUSTER KIND NAME MESSAGE
HIGH exposure prod-usw2 Cluster prod-usw2 the Kubernetes API endpoint is publicly accessible with publicAccessCidrs 0.0.0.0/0
MEDIUM autoscaler-conflict demo-use1 NodeGroup system scalingConfig.desiredSize (3) is set under forProvider with Update management: Crossplane will reconcile it on every pass, fighting Cluster Autoscaler or Karpenter
MEDIUM autoscaler-conflict demo-use1 NodeGroup system-v2 scalingConfig.desiredSize (3) is set under forProvider with Update management: Crossplane will reconcile it on every pass, fighting Cluster Autoscaler or Karpenter
MEDIUM autoscaler-conflict prod-usw2 NodeGroup main scalingConfig.desiredSize (4) is set under forProvider with Update management: Crossplane will reconcile it on every pass, fighting Cluster Autoscaler or Karpenter
MEDIUM exposure prod-usw2 Cluster prod-usw2 no encryptionConfig: Kubernetes Secrets are not encrypted with a KMS key
MEDIUM exposure staging-use1 Cluster staging-use1 no encryptionConfig: Kubernetes Secrets are not encrypted with a KMS key
LOW version-skew demo-use1 NodeGroup batch node group is on 1.34, 1 minor version(s) behind the control plane (1.35)
LOW version-skew demo-use1 NodeGroup system node group is on 1.34, 1 minor version(s) behind the control plane (1.35)
LOW version-skew demo-use1 NodeGroup system-v2 node group is on 1.34, 1 minor version(s) behind the control plane (1.35)
LOW exposure prod-usw2 Cluster prod-usw2 control-plane logging is missing: audit, authenticator
(The demo-use1 cluster was created to test the guardrails.)
cub-eks plan can flag changes that would cause disruptions, such as node re-creation, or cause Crossplane’s provider implementation to not be able to update the cluster or nodepool due to immutable AWS API fields.
% cub-eks plan -o table
DISRUPTION CLUSTER UNIT REV RESOURCE CHANGED
replace demo-use1 ng-system 1->3 system instanceTypes.0, scalingConfig.maxSize
rolling demo-use1 ng-batch 2->3 batch version
in-place-disruptive demo-use1 cluster 1->2 demo-use1 version
eks-demo-use1/ng-system — REPLACE
instanceTypes.0: instanceTypes is immutable
This cannot be reconciled in place. Crossplane will refuse the update and
retry forever; the Unit will read as applied while AWS is never changed.
-> this replaces the node group — use `replace-nodegroup` for a blue/green swap, or revert the field
3 Unit(s) with pending changes: 1 blocking, 1 rolling, 1 in-place.
43 Unit(s) have never been applied (no baseline to compare against).
In order to do these kinds of checks prior to deployment when using Crossplane compositions, one would need to render the compositions client-side, such as in CI.
We can also just inspect the state with domain-specific commands — snapshot, versions, list, and get:
% cub-eks snapshot -o table
CLUSTER VERSION REGION MODE NODEGROUPS ADDONS NETWORK IAM UNITS GATED UNAPPLIED
demo-use1 1.35 us-east-1 auto 3 1 2 1 8 0 8
prod-usw2 1.33 us-west-2 classic 1 0 0 0 2 0 2
staging-use1 1.34 us-east-1 auto 1 1 24 9 36 0 36
3 clusters, 5 nodegroups, 2 addons, 46 units (0 gated, 46 unapplied)
% cub-eks versions -o table
CLUSTER ENV VERSION SUPPORT MODE NODEGROUPS MAX SKEW ADDONS
demo-use1 dev 1.35 EXTENDED auto 3 1 behind 1
prod-usw2 prod 1.33 EXTENDED classic 1 - 0
staging-use1 staging 1.34 EXTENDED auto 1 - 1
versions: 1.35 x1, 1.34 x1, 1.33 x1
3 clusters, 3 distinct versions, 1 with node-group skew, 0 pinned under STANDARD support
% cub-eks list -o table
CLUSTER GROUP KIND NAME UNIT
...
staging-use1 ec2 EIP staging-use1-nat-a-eip eip-nat-a
staging-use1 ec2 InternetGateway staging-use1-igw igw
staging-use1 ec2 NATGateway staging-use1-nat-a nat-a
staging-use1 ec2 Route staging-use1-private-a-default route-private-a-nat
staging-use1 ec2 Route staging-use1-private-b-default route-private-b-nat
staging-use1 ec2 Route staging-use1-private-c-default route-private-c-nat
staging-use1 ec2 Route staging-use1-public-default route-public-igw
staging-use1 ec2 RouteTable staging-use1-private-a-rt rt-private-a
staging-use1 ec2 RouteTable staging-use1-private-b-rt rt-private-b
staging-use1 ec2 RouteTable staging-use1-private-c-rt rt-private-c
staging-use1 ec2 RouteTable staging-use1-public-rt rt-public
staging-use1 ec2 RouteTableAssociation staging-use1-private-a-assoc rta-private-a
staging-use1 ec2 RouteTableAssociation staging-use1-private-b-assoc rta-private-b
staging-use1 ec2 RouteTableAssociation staging-use1-private-c-assoc rta-private-c
staging-use1 ec2 RouteTableAssociation staging-use1-public-a-assoc rta-public-a
staging-use1 ec2 RouteTableAssociation staging-use1-public-b-assoc rta-public-b
staging-use1 ec2 RouteTableAssociation staging-use1-public-c-assoc rta-public-c
staging-use1 ec2 Subnet staging-use1-private-a subnet-private-a
staging-use1 ec2 Subnet staging-use1-private-b subnet-private-b
staging-use1 ec2 Subnet staging-use1-private-c subnet-private-c
staging-use1 ec2 Subnet staging-use1-public-a subnet-public-a
staging-use1 ec2 Subnet staging-use1-public-b subnet-public-b
staging-use1 ec2 Subnet staging-use1-public-c subnet-public-c
staging-use1 ec2 VPC staging-use1-vpc vpc
staging-use1 eks Addon aws-efs-csi-driver addon-aws-efs-csi-driver
staging-use1 eks Cluster staging-use1 cluster
staging-use1 eks NodeGroup batch nodegroup-batch
staging-use1 iam Role staging-use1-cluster-role cluster-role
staging-use1 iam Role staging-use1-node-role node-role
staging-use1 iam RolePolicyAttachment staging-use1-cluster-role-AmazonEKSBlockStoragePolicyV2 cluster-role-amazoneksblockstoragepolicyv2
staging-use1 iam RolePolicyAttachment staging-use1-cluster-role-AmazonEKSClusterPolicy cluster-role-amazoneksclusterpolicy
staging-use1 iam RolePolicyAttachment staging-use1-cluster-role-AmazonEKSComputePolicy cluster-role-amazonekscomputepolicy
staging-use1 iam RolePolicyAttachment staging-use1-cluster-role-AmazonEKSLoadBalancingPolicy cluster-role-amazoneksloadbalancingpolicy
staging-use1 iam RolePolicyAttachment staging-use1-cluster-role-AmazonEKSNetworkingPolicy cluster-role-amazoneksnetworkingpolicy
staging-use1 iam RolePolicyAttachment staging-use1-node-role-AmazonEC2ContainerRegistryPullOnly node-role-amazonec2containerregistrypullonly
staging-use1 iam RolePolicyAttachment staging-use1-node-role-AmazonEKSWorkerNodeMinimalPolicy node-role-amazoneksworkernodeminimalpolicy
46 resources
% cub-eks get demo-use1 -o table
CLUSTER demo-use1
control plane demo-use1
apiVersion eks.aws.upbound.io/v1beta2
version 1.35
region us-east-1
compute auto mode (general-purpose,system)
endpoint private=yes public=no
logging api,audit,authenticator
encryption yes
auth mode API
support type EXTENDED
deletion policy Orphan
mgmt policies Observe,Create,Update,Delete
unit eks-demo-use1/cluster
NODE GROUPS
NAME VERSION INSTANCE TYPES CAPACITY MIN MAX DESIRED UNIT
batch 1.34 m6i.xlarge,m6a.xlarge SPOT 0 20 2 (init) ng-batch
system 1.34 m6i.xlarge ON_DEMAND 3 12 3 ng-system
system-v2 1.34 m6i.2xlarge ON_DEMAND 3 12 3 nodegroup-system-v2
ADDONS
NAME VERSION ON UPDATE PRESERVE UNIT
vpc-cni v1.19.5-eksbuild.1 OVERWRITE yes addon-vpc-cni
NETWORKING
Subnet 1 demo-use1-private-a
VPC 1 demo-use1-vpc
IAM
Role 1 demo-use1-cluster-role
Remember, this is a fleet-wide view. Using the default JSON output, AI agents can also build summary tables (e.g., using python) based on what aspects you’d like to see.
But it’s the commands that update the configuration that reach beyond what we could do with configuration as code.
These clusters and nodepools were created with cub-eks create, using the typical CLI approach of specifying values using command-line flags rather than a configuration file. Unlike configuration as code, we don’t need to regenerate the resources in order to make changes, so we don’t need to save an input configuration file as a source of truth. We can just change the resources. For safety, the mutating commands, such as scale and upgrade, use dry run by default. When “committing” changes (without dry run), I required change descriptions and asked the agent to convey what it was asked to do, but you could do differently if you preferred.
% cub-eks scale nodegroup eks-demo-use1/ng-system --nodes-max 15 -o table
PATH VALUE DISRUPTION
scalingConfig.maxSize 15 in-place
Dry run — 1 resource(s) would change in eks-demo-use1/ng-system. Re-run with --commit --change-desc "…" to write.
% cub-eks upgrade nodegroup eks-demo-use1/ng-system --to 1.35 -o table
PATH VALUE DISRUPTION
version 1.35 rolling
warning: EKS will drain and replace every node in this group; check updateConfig.maxUnavailable and PodDisruptionBudgets first
Dry run — 1 resource(s) would change in eks-demo-use1/ng-system. Re-run with --commit --change-desc "…" to write.
% cub-eks upgrade cluster demo-use1 --to 1.36 -o table
Upgrade demo-use1: 1.35 -> 1.36
# STAGE RESOURCE FROM TO DISRUPTION STATUS
1 nodegroup-catchup batch 1.34 1.35 rolling REQUIRED FIRST
2 nodegroup-catchup system 1.34 1.35 rolling REQUIRED FIRST
3 nodegroup-catchup system-v2 1.34 1.35 rolling REQUIRED FIRST
4 control-plane demo-use1 1.35 1.36 in-place-disruptive pending
5 addon vpc-cni v1.19.5-eksbuild.1 (compatible with 1.36) in-place-disruptive pending
6 nodegroup batch 1.34 1.36 rolling pending
7 nodegroup system 1.34 1.36 rolling pending
8 nodegroup system-v2 1.34 1.36 rolling pending
1. batch: behind the current control plane; catch up before the control plane moves
2. system: behind the current control plane; catch up before the control plane moves
3. system-v2: behind the current control plane; catch up before the control plane moves
4. demo-use1: multi-minute; EKS allows only one cluster update at a time
5. vpc-cni: check the addon version compatible with 1.36 before setting it
6. batch: drains and replaces every node in the group
7. system: drains and replaces every node in the group
8. system-v2: drains and replaces every node in the group
BLOCKED — node groups are behind the current control plane and must be caught up
first. Moving the control plane now would strand them further behind than EKS
supports, and node groups only advance one minor version at a time.
Run these, apply each, and wait for the nodes to roll:
bin/cub-eks upgrade nodegroup eks-demo-use1/ng-batch --to 1.35 --commit --change-desc "…"
bin/cub-eks upgrade nodegroup eks-demo-use1/ng-system --to 1.35 --commit --change-desc "…"
bin/cub-eks upgrade nodegroup eks-demo-use1/nodegroup-system-v2 --to 1.35 --commit --change-desc "…"
Then re-run this command.
Note that the last upgrade command was blocked by the prerequisite checks. The system-v2 nodegroup was created by cub-eks replace-nodegroup, which is used to create a new nodegroup that changes some immutable field of an existing nodegroup. eksctl doesn’t have a command to do this, but has instructions.
We could also implement more sophisticated orchestration and workflows in such tools, though we might also want to implement it as a server, such as a MCP server, if we did that. Such a server could be run with the ability to access the cluster, so that it could orchestrate the draining of the nodes, for example. Note that the workload-manager tool is responsible for ensuring that the workloads have PodDisruptionBudget and proper anti-affinity constraints.
There’s also a fleet-edit command for making mass changes across multiple clusters — again, starting with dry run by default. It automatically chooses an appropriate function to run to change the specified fields based on their data types. These changes are validated by Triggers and can be reviewed before being released to GitOps tools like ArgoCD and Flux.
In the case that clusters and nodepools should have similar properties, they can be created as variants of the same base configuration. One would then first update the base and upgrade the downstream variants. The cub-eks promote command will do that, but so will the standard cub variant promote. This enables similar inheritance as compositions, templates, or modules, but with full transparency and granular control over blast radius.
I only spent about a day on this tool. I’m sure a lot more could be done, such as creating the MCP server version of the tool, or adding support for Karpenter, or (v2) namespace-scoped Crossplane resources, or ACK, or being able to reuse an existing VPC. I also didn’t yet create agent skills the way I did for the other tools. Would you simplify the cluster and/or nodegroup creation options, and make it more opinionated? What would be most useful?
What I find compelling about this is that we can use AI to build deterministic, testable, ergonomic, domain-specific tools that can inspect, modify, and validate the desired state based on standard, stable APIs. As opposed to AI agents directly editing layers of bespoke snowflake templates that are then rendered at apply time and generally would not have this level of domain-specific validation and impact analysis. The ability to orchestrate changes, similar to Kubernetes controllers, but with pre-deployment validation, versioning, etc. is also enticing, and I plan to explore that more in the future.
What kinds of operational changes do you “break glass” to make directly to the live state because of the complexity of making the changes through configuration as code or infrastructure as code? What kinds of operational tools would you build if it were feasible to do without creating configuration drift? Do you often need to make similar changes to different compositions, templates, or modules? Can you query your input values across your fleet? How about the rendered output? Do you generally make changes to input values by hand or using tools? How do you decouple operational concerns from developer concerns? What do you do when one-size-fits-all abstractions block valid use cases? Are there other CloudFormation-based tools that you wish could target Crossplane or ACK resources?
Reply here, or send me a message on LinkedIn, X/Twitter, or Bluesky, where I plan to crosspost this.
You could also try out ConfigHub, which is now in preview.
If you found this interesting, you may be interested in my other posts.
Thanks to Ilya Dmitrichenko, creator of eksctl, for providing valuable feedback on this post.
