Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions internal/client/client_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ func TestClient_V1Alpha1RoundTrip(t *testing.T) {

mux := http.NewServeMux()
api := humago.New(mux, huma.DefaultConfig("test", "v1"))
crud.Register(api, "/v0", stores, nil, nil, crud.PerKindHooks{})
crud.Register(api, "/v0", stores, nil, nil, crud.PerKindHooks{}, nil)
resource.RegisterApply(api, resource.ApplyConfig{
BasePrefix: "/v0",
Stores: stores,
Expand Down Expand Up @@ -143,7 +143,7 @@ func TestClient_V1Alpha1_NotFound(t *testing.T) {

mux := http.NewServeMux()
api := humago.New(mux, huma.DefaultConfig("test", "v1"))
crud.Register(api, "/v0", stores, nil, nil, crud.PerKindHooks{})
crud.Register(api, "/v0", stores, nil, nil, crud.PerKindHooks{}, nil)

ts := httptest.NewServer(mux)
defer ts.Close()
Expand Down
3 changes: 3 additions & 0 deletions internal/registry/api/handlers/v0/crud/crud.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"github.com/agentregistry-dev/agentregistry/pkg/api/v1alpha1"
"github.com/agentregistry-dev/agentregistry/pkg/registry/resource"
"github.com/agentregistry-dev/agentregistry/pkg/registry/v1alpha1store"
"github.com/agentregistry-dev/agentregistry/pkg/types"
)

// PerKindHooks groups optional, per-kind callbacks layered on top of
Expand Down Expand Up @@ -70,6 +71,7 @@ func Register(
resolver v1alpha1.ResolverFunc,
registryValidator v1alpha1.RegistryValidatorFunc,
perKind PerKindHooks,
deleteAdmission types.DeleteAdmission,
) {
cfgFor := func(kind string) (resource.Config, bool) {
store, ok := stores[kind]
Expand All @@ -86,6 +88,7 @@ func Register(
ListFilter: perKind.ListFilters[kind],
PostUpsert: perKind.PostUpserts[kind],
PostDelete: perKind.PostDeletes[kind],
DeleteAdmission: deleteAdmission,
InitialFinalizers: perKind.InitialFinalizers[kind],
}, true
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ func seedDeploymentFixtures(t *testing.T) (humatest.TestAPI, map[string]*v1alpha
},
},
},
nil,
)
deploymentlogs.Register(api, deploymentlogs.Config{
BasePrefix: "/v0",
Expand Down
68 changes: 64 additions & 4 deletions internal/registry/api/router/v0.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"github.com/agentregistry-dev/agentregistry/pkg/api/v1alpha1/registries"
"github.com/agentregistry-dev/agentregistry/pkg/registry/resource"
"github.com/agentregistry-dev/agentregistry/pkg/registry/v1alpha1store"
"github.com/agentregistry-dev/agentregistry/pkg/types"
)

// Stores is the per-kind Store map used by the v1alpha1
Expand Down Expand Up @@ -73,6 +74,28 @@ type RouteOptions struct {

// Optional callback for integration-owned route registration.
ExtraRoutes func(api huma.API, pathPrefix string)

// Admission optionally owns the final apply write. Nil preserves OSS
// production writes through resource.ProductionAdmission.
// TODO(krt): temporary synchronous-handler bridge; remove when KRT owns
// admission/staging.
Admission types.Admission

// DeleteAdmission optionally owns the final delete. Nil preserves OSS
// production deletes through resource.ProductionDeleteAdmission.
// TODO(krt): temporary synchronous-handler bridge; remove when KRT owns
// admission/staging.
DeleteAdmission types.DeleteAdmission

// ResolverWrapper decorates the shared ResourceRef resolver before
// resource and apply routes are registered.
// TODO(krt): temporary bridge for pending staged refs during HTTP apply.
ResolverWrapper func(v1alpha1.ResolverFunc) v1alpha1.ResolverFunc

// ExtraResourceRoutes registers adjacent routes with access to the same
// v1alpha1 stores and hooks used by /v0/apply.
// TODO(krt): temporary bridge for downstream synchronous approval routes.
ExtraResourceRoutes func(api huma.API, pathPrefix string, ctx types.ResourceRouteContext)
}

// RegisterRoutes registers all API routes under /v0. Required
Expand Down Expand Up @@ -109,6 +132,10 @@ func RegisterRoutes(
opts.DeploymentCoordinator,
opts.PerKindHooks,
opts.RegistryValidator,
opts.Admission,
opts.DeleteAdmission,
opts.ResolverWrapper,
opts.ExtraResourceRoutes,
)

if opts.ExtraRoutes != nil {
Expand Down Expand Up @@ -136,8 +163,15 @@ func registerKindRoutes(
coord *deploymentsvc.Coordinator,
perKind crud.PerKindHooks,
registryValidator v1alpha1.RegistryValidatorFunc,
) {
admission types.Admission,
deleteAdmission types.DeleteAdmission,
resolverWrapper func(v1alpha1.ResolverFunc) v1alpha1.ResolverFunc,
extraResourceRoutes func(api huma.API, pathPrefix string, ctx types.ResourceRouteContext),
) resource.ApplyConfig {
resolver := internaldb.NewResolver(stores)
if resolverWrapper != nil {
resolver = resolverWrapper(resolver)
}
if registryValidator == nil {
registryValidator = registries.Dispatcher
}
Expand Down Expand Up @@ -174,7 +208,7 @@ func registerKindRoutes(

// Per-kind CRUD endpoints — one call per built-in kind, hidden
// inside crud.Register.
crud.Register(api, basePrefix, stores, resolver, registryValidator, perKind)
crud.Register(api, basePrefix, stores, resolver, registryValidator, perKind, deleteAdmission)

// Deployment-specific endpoints: logs stream (cancel is subsumed
// by DesiredState=undeployed + DELETE in the v1alpha1 lifecycle).
Expand All @@ -191,7 +225,7 @@ func registerKindRoutes(
// same per-kind hook table populated above, so Deployment reconciliation
// and any caller-supplied PostUpsert/PostDelete fire identically on
// the batch path.
resource.RegisterApply(api, resource.ApplyConfig{
applyCfg := resource.ApplyConfig{
BasePrefix: basePrefix,
Stores: stores,
Resolver: resolver,
Expand All @@ -200,5 +234,31 @@ func registerKindRoutes(
PostUpserts: perKind.PostUpserts,
PostDeletes: perKind.PostDeletes,
InitialFinalizers: perKind.InitialFinalizers,
})
Admission: admission,
DeleteAdmission: deleteAdmission,
}
productionApplyCfg := applyCfg
productionApplyCfg.Admission = resource.ProductionAdmission
productionDeleteCfg := applyCfg
productionDeleteCfg.DeleteAdmission = resource.ProductionDeleteAdmission
resource.RegisterApply(api, applyCfg)

if extraResourceRoutes != nil {
opaqueStores := make(map[string]any, len(stores))
for kind, store := range stores {
opaqueStores[kind] = store
}
extraResourceRoutes(api, basePrefix, types.ResourceRouteContext{
Stores: opaqueStores,
Resolver: resolver,
RegistryValidator: registryValidator,
Apply: func(ctx context.Context, obj v1alpha1.Object, dryRun bool) arv0.ApplyResult {
return resource.ApplyObject(ctx, productionApplyCfg, obj, dryRun)
},
Delete: func(ctx context.Context, obj v1alpha1.Object, dryRun bool) arv0.ApplyResult {
return resource.DeleteObject(ctx, productionDeleteCfg, obj, dryRun)
},
})
}
return applyCfg
}
12 changes: 8 additions & 4 deletions internal/registry/registry_app.go
Original file line number Diff line number Diff line change
Expand Up @@ -213,10 +213,14 @@ func buildRouteOptions(
adapters map[string]types.DeploymentAdapter,
) *router.RouteOptions {
routeOpts := &router.RouteOptions{
ExtraRoutes: options.ExtraRoutes,
Stores: stores,
PerKindHooks: crudPerKindHooks(options),
RegistryValidator: options.RegistryValidator,
ExtraRoutes: options.ExtraRoutes,
Stores: stores,
PerKindHooks: crudPerKindHooks(options),
RegistryValidator: options.RegistryValidator,
Admission: options.Admission,
DeleteAdmission: options.DeleteAdmission,
ResolverWrapper: options.ResolverWrapper,
ExtraResourceRoutes: options.ExtraResourceRoutes,
}

if stores != nil {
Expand Down
22 changes: 22 additions & 0 deletions internal/registry/service/deployment/coordinator.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,10 +95,16 @@ func (c *Coordinator) Apply(ctx context.Context, deployment *v1alpha1.Deployment

target, err := c.resolveTarget(ctx, deployment)
if err != nil {
if errors.Is(err, v1alpha1.ErrDanglingRef) {
return c.persistReferencePending(ctx, deployment, err)
}
Comment on lines +98 to +100
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: What happens here if targetRef/runtimeRef is genuinely missing (typo / deleted resource)? Should we split into ErrPendingRef vs ErrDanglingRef, so a typo'd ref still fails loudly?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typo still fails earlier: applyCore runs ResolveObjectRefs before persistence, so bad target/runtime ref does not write Deployment row

return err
}
runtime, err := c.resolveRuntime(ctx, deployment)
if err != nil {
if errors.Is(err, v1alpha1.ErrDanglingRef) {
return c.persistReferencePending(ctx, deployment, err)
}
return err
}

Expand All @@ -124,6 +130,22 @@ func (c *Coordinator) Apply(ctx context.Context, deployment *v1alpha1.Deployment
return c.persistApplyResult(ctx, deployment, result)
}

func (c *Coordinator) persistReferencePending(ctx context.Context, deployment *v1alpha1.Deployment, cause error) error {
message := "referenced resource is not available yet"
if cause != nil {
message = cause.Error()
}
return c.persistApplyResult(ctx, deployment, &types.ApplyResult{
Conditions: []v1alpha1.Condition{{
Type: "Ready",
Status: v1alpha1.ConditionFalse,
Reason: "ReferencePending",
Message: message,
ObservedGeneration: deployment.Metadata.Generation,
}},
})
}

// Remove tears down a Deployment's runtime resources via the adapter and
// merges the resulting Removed condition into the row's status. Called
// after the row's DeletionTimestamp is set (soft-delete path) or when
Expand Down
13 changes: 11 additions & 2 deletions internal/registry/service/deployment/coordinator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,8 +168,17 @@ func TestCoordinator_DanglingTargetRef(t *testing.T) {
})

err := coord.Apply(ctx, deployment)
require.Error(t, err)
require.ErrorIs(t, err, v1alpha1.ErrDanglingRef)
require.NoError(t, err)

raw, err := stores[v1alpha1.KindDeployment].Get(ctx, deployment.Metadata.Namespace, deployment.Metadata.Name, "")
require.NoError(t, err)
var status v1alpha1.Status
require.NoError(t, v1alpha1.UnmarshalStatusFromStorage(raw.Status, &status))
ready := status.GetCondition("Ready")
require.NotNil(t, ready)
require.Equal(t, v1alpha1.ConditionFalse, ready.Status)
require.Equal(t, "ReferencePending", ready.Reason)
require.Contains(t, ready.Message, "does-not-exist")
}

func TestCoordinator_Discover_ReturnsAdapterResults(t *testing.T) {
Expand Down
3 changes: 2 additions & 1 deletion pkg/api/v0/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ type ApplyResult struct {
Namespace string `json:"namespace,omitempty"`
Name string `json:"name"`
Tag string `json:"tag,omitempty"`
// Status is one of: created, configured, unchanged, deleted,
// Status is one of: created, configured, unchanged, staged, deleted,
// dry-run, failed. Matches kubectl-style apply output.
Status string `json:"status"`
// Generation is the server-managed generation after the apply.
Expand All @@ -26,6 +26,7 @@ const (
ApplyStatusCreated = "created"
ApplyStatusConfigured = "configured"
ApplyStatusUnchanged = "unchanged"
ApplyStatusStaged = "staged"
ApplyStatusDeleted = "deleted"
ApplyStatusDryRun = "dry-run"
ApplyStatusFailed = "failed"
Expand Down
Loading
Loading