Created: 2026-03-25 Status: proposed Bead: gcx-experiments-dvwd Supersedes: none
Every provider in gcx has two parallel CRUD code paths:
- Provider CLI commands (
gcx slo definitions list) call REST clients directly, working with typed structs (Slo,Check, etc.). - Resources pipeline (
gcx resources get slos) goes throughResourceAdapter, which erases type information via*unstructured.Unstructured.
The TypedCRUD[T any] generic (introduced in the TypedResourceAdapter Foundation
spec) absorbs boilerplate but immediately erases T through AsAdapter(). Provider
commands cannot use TypedCRUD for typed access because it exposes no typed public
methods — only function pointers and an unstructured bridge.
Additionally:
Provider.ResourceAdapters() []adapter.Factoryis dead code — no call sites exist. All adapter registration flows throughinit()→adapter.Register().NameFn,RestoreNameFn, andMetadataFnfunction pointers onTypedCRUDreplicate what K8s metadata accessors (GetName(),SetName()) provide natively.- Two global registries coexist:
providers.Register()for CLI identity andadapter.Register()for resource adapters, with no connection between them. Eight providers use 13init()functions across three different patterns (split, combined, sub-package) to populate both registries independently.
The consolidation plan requires porting 40+ resource types from the cloud CLI. Each new provider would perpetuate the dual code path and dual registration if the architecture is not addressed.
Prior work: docs/specs/feature-typed-resource-adapter-foundation/spec.md
(TypedCRUD[T] generic), docs/specs/feature-fold-provider-crud-into-resources/spec.md
(ResourceAdapter interface and unified CRUD routing).
Research: docs/research/2026-03-25-provider-registry-convergence.md (analysis of
Provider interface, registry patterns, non-CRUD command inventory, migration path).
We will introduce three new abstractions, unify the dual registration pattern, and
migrate all provider CRUD commands to use TypedCRUD[T] as their service layer.
Domain types implement this to bridge their identity field to K8s metadata.name:
type ResourceIdentity interface {
GetResourceName() string
SetResourceName(string)
}This replaces NameFn and RestoreNameFn on TypedCRUD. The contract is: given a
K8s name, SetResourceName restores enough identity to make API calls work. The
mapping may be asymmetric (e.g., fleet's Pipeline generates a composite slug from
Name + ID but only restores ID from the slug).
All ~16 existing domain types add these two methods (~50 LOC total).
Standard K8s CRD pattern — wraps any T ResourceIdentity with K8s metadata:
type TypedObject[T ResourceIdentity] struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata"`
Spec T `json:"spec"`
}TypedObject[Slo] implements metav1.Object via embedded ObjectMeta — standard
K8s accessors (GetName(), GetNamespace(), GetLabels(), etc.) work natively.
TypedCRUD[T any] becomes TypedCRUD[T ResourceIdentity]. New typed methods:
func (c *TypedCRUD[T]) List(ctx context.Context) ([]TypedObject[T], error)
func (c *TypedCRUD[T]) Get(ctx context.Context, name string) (*TypedObject[T], error)
func (c *TypedCRUD[T]) Create(ctx context.Context, obj *TypedObject[T]) (*TypedObject[T], error)
func (c *TypedCRUD[T]) Update(ctx context.Context, name string, obj *TypedObject[T]) (*TypedObject[T], error)
func (c *TypedCRUD[T]) Delete(ctx context.Context, name string) errorAsAdapter() continues to return ResourceAdapter for the unstructured pipeline.
Removed from TypedCRUD: NameFn, RestoreNameFn (replaced by ResourceIdentity).
Kept: MetadataFn (for extra metadata beyond what ObjectMeta covers),
StripFields (spec-level key removal before serialization).
Replace the dual init() registration pattern with a single path. The Provider
interface evolves:
type Provider interface {
Name() string
ShortDesc() string
Commands() []*cobra.Command
Validate(cfg map[string]string) error
ConfigKeys() []ConfigKey
Registrations() []adapter.Registration // NEW — provider owns its adapter registrations
// ResourceAdapters() REMOVED — dead code, subsumed by Registrations()
}providers.Register() auto-registers adapters:
func Register(p Provider) {
registry = append(registry, p)
for _, r := range p.Registrations() {
adapter.Register(r)
}
}Each provider moves its adapter.Register() calls from separate init() functions
into the Registrations() method. Registration construction logic is unchanged —
just relocated. This collapses 13 init() functions down to 8 (one per provider)
and makes Provider the single owner of its adapter registrations.
Example for SLO:
// Before: two init() in two files
// slo/provider.go: providers.Register(&SLOProvider{})
// slo/definitions/resource_adapter.go: adapter.Register(Registration{...})
// After: one init(), one method
func (p *SLOProvider) Registrations() []adapter.Registration {
return []adapter.Registration{definitions.StaticRegistration()}
}
// init() only has: providers.Register(&SLOProvider{})
// adapter.Register() call deleted from definitions/resource_adapter.goNo circular import risk — internal/providers/provider.go already imports
internal/resources/adapter for the existing Factory type.
All provider CRUD commands migrate from direct REST client calls to TypedCRUD[T].
Each provider package exposes a shared typed factory:
func NewTypedCRUD(ctx context.Context) (*adapter.TypedCRUD[Slo], error)Used by both CLI commands (typed access via .Spec) and adapter registration
(unstructured access via AsAdapter()). This is the single construction point that
eliminates the dual code path.
TypedObject[T] provides K8s-compatible metadata, but StripFields operates at the
spec map level and requires JSON→map→delete. The approach is hybrid:
- Typed methods (
List,Get, etc.): BuildTypedObject[T]with proper metadata, useruntime.DefaultUnstructuredConverterfor full conversion where possible. - AsAdapter bridge: Keeps the existing JSON→map→strip→envelope approach for spec
serialization to maintain backward compatibility with
StripFields.
- Interface constraint on T for full
metav1.Object: Too heavy —metav1.Objecthas ~30 methods. Domain types would need massive boilerplate orObjectMetaembedding that changes their JSON serialization. - Convertible domain types (types carry own conversion): Clean but requires modifying
all domain types with
ToUnstructured()/FromUnstructured()methods — heavier thanResourceIdentity's 2 methods. - Keep
NameFn/RestoreNameFnas config: Pragmatic but misses the opportunity to make domain types self-describing.ResourceIdentityis minimal (2 methods) and enables future K8s machinery use. - Keep dual registries, defer unification: The research report
(
docs/research/2026-03-25-provider-registry-convergence.md) confirmed thatProvider.Registrations()is a ~100-150 LOC change that collapses 13 init() into 8 with zero new logic — just relocation. No reason to defer when the lift is this small. - Full registry convergence with ProviderMeta: The research report proposes a
ProviderMetatype andRegisterProvider()API that fully decouples provider metadata from the Provider interface. This is the right long-term target but is a larger refactor (moving ConfigKeys, Validate, rewritingproviders list). TheRegistrations()approach gets 90% of the benefit with 10% of the effort.
- Single CRUD code path: Provider commands and resources pipeline share
TypedCRUD[T]as the service layer. Bug fixes apply to both paths. - Typed access for provider commands: Commands get
TypedObject[T]with.Spectyped access instead of hand-rolling REST client calls. - K8s metadata compatibility:
TypedObject[T]implementsmetav1.Object— domain objects can participate in K8s tooling that expects standard accessors. - Self-describing domain types:
ResourceIdentityeliminates per-adapter function pointer configuration for name mapping. - Unified registration: Provider owns its adapter registrations via
Registrations(). Singleinit()per provider, singleproviders.Register()call populates both registries atomically. Eliminates the dual init() pattern (13 → 8). - Dead code removal:
Provider.ResourceAdapters()and its 9 implementations (mostlyreturn nil) are removed, subsumed byRegistrations().
- Hybrid serialization: Two conversion paths (typed methods vs. AsAdapter bridge)
coexist until
StripFieldsis resolved at a higher level. - Provider command migration is breadth work: ~8 providers, each with multiple CRUD commands. Mechanical but touches many files. Atomic commits per provider mitigate risk.
SetResourceNameerror swallowing: k6 and synth types with int IDs silently discard parse errors, matching currentRestoreNameFnbehavior but baked into a formal interface.
- Full registry convergence: Introduce
ProviderMetatype, move ConfigKeys/Validate out of Provider interface, enableproviders listfrom a unified registry. Seedocs/research/2026-03-25-provider-registry-convergence.mdphases 2-6. - StripFields elimination: Investigate using struct tags or separate spec types to avoid the JSON→map→delete pattern entirely.
- Provider command deprecation: After migration, deprecate provider-specific CRUD
commands in favor of
gcx resourcesequivalents.