Skip to content
Closed
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
149 changes: 149 additions & 0 deletions docs/design/editor-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
# Design: `editor.ui.k8s.appscode.com` extended API

| | |
|---|---|
| **Status** | Implemented (read-only scope) |
| **Author** | tamal@appscode.com |
| **Date** | 2026-06-26 |
| **Repos** | `kmodules.xyz/resource-metadata` (types), `kubeops.dev/ui-server` (registry), `go.bytebuilders.dev/b3` (consumer) |

## 1. Summary

The "editor" experience — render a chart's model/manifest/resources from a set
of options, and load those for an existing installation — was implemented in the
AppsCode platform server **b3** as HTTP handlers under
`routers/api/v1/clusters.go` (the `/options` and `/editor` route groups). Those
handlers call `kubepack.dev/lib-app/pkg/editor` directly against a target
cluster reached via a kubeconfig.

This design moves the **read-only** subset of that surface into the cluster as a
native aggregated Kubernetes API group, `editor.ui.k8s.appscode.com/v1alpha1`,
served by `kube-ui-server`. Clients (AceUI / Console, and b3) talk to it through
the Kubernetes API — with RBAC, impersonation, and audit — instead of a bespoke
endpoint.

**Scope: read-only only.** Apply (`PUT /editor/`) and delete (`DELETE /editor/`)
remain entirely in b3 (NATS TaskMgr flow) and are out of scope here.

## 2. Goals / Non-goals

### Goals
- Expose the 6 read-only b3 editor endpoints as aggregated API resources.
- Put the request/response types upstream in `resource-metadata` (alongside the
other `meta`/`ui` aggregated types), not local to ui-server.
- Reuse the already-vendored `kubepack.dev/lib-app/pkg/editor` logic so behavior
matches b3.
- **Authorize the resources touched as the extended-API caller**, via
impersonation.

### Non-goals
- Apply/delete (mutating). Left as-is in b3.
- b3's platform orchestration (NATS task queue, EKS IRSA creds, feature-set
auto-enable, observability wiring).
- Persisting editor objects — these are non-persisted, create-only RPC resources.

## 3. API (resource-metadata)

New group `editor.ui.k8s.appscode.com`, version `v1alpha1`
(`apis/editor/{doc.go,install,v1alpha1}`). Two create-only, cluster-scoped
action kinds (`+genclient:onlyVerbs=create`, `Request`/`Response` in one object,
like `meta.Render` / `meta.ResourceManifests`). The 6 b3 endpoints collapse to
2 kinds via an `output` discriminator (`model` | `manifest` | `resources`).

### `EditorRender` — render from options (`/options/*`)
```go
type EditorRenderRequest struct {
ChartRef *releasesapi.ChartSourceFlatRef // optional; else resource-editor chart
Options *runtime.RawExtension // the options/values model
Output EditorOutput // model | manifest | resources (default resources)
SkipCRDs bool
}
type EditorRenderResponse struct {
Model *runtime.RawExtension // output=model
Manifest string // output=manifest
Resources *releasesapi.ResourceOutput // output=resources
}
```
Backed by `editor.GenerateResourceEditorModel` (model),
`editor.RenderResourceEditorChart` (manifest), and
`editor.RenderChart`/`RenderResourceEditorChart` (resources, with the kubedb-first
sort + `skipCRDs` from b3 `PreviewEditorResources`).

### `EditorTemplate` — load from an existing install (`/editor/{model,manifest,resources}`)
```go
type EditorTemplateRequest struct {
ChartRef *releasesapi.ChartSourceFlatRef
Metadata releasesapi.Metadata // resource id + release name/namespace
Output EditorOutput
}
type EditorTemplateResponse struct {
Values *runtime.RawExtension // output=model
Manifest string // output=manifest
Resources *releasesapi.ResourceOutput // output=resources
}
```
Backed by `editor.LoadEditorModel`/`LoadResourceEditorModel`. Unlike b3's load
handlers, it does **not** call `CreateAppReleaseIfMissing` (that is a write) —
keeping the resource read-only, matching the existing `meta.resourcemanifests`
storage.

> No CRD yaml is generated for these kinds — like `render`/`resourcemanifests`/
> `costreport`, they are served by the aggregated apiserver, not CRD-backed.
> `make gen` produces deepcopy + openapi + clientset only.

## 4. Registry & authorization (ui-server)

Storages under `pkg/registry/editor/{render,template}`, each implementing
`rest.Creater`, registered in `pkg/apiserver/apiserver.go` under a new
`InstallAPIGroup` block (+ `editorinstall.Install(Scheme)`).

**Authorization — act as the caller.** A shared helper
(`pkg/registry/editor/editorutil`) builds, per request, a controller-runtime
client and lib-helm chart registry that **impersonate the API caller**:

```go
user, _ := apirequest.UserFrom(ctx)
impCfg := rest.CopyConfig(cfg)
impCfg.Impersonate = rest.ImpersonationConfig{
UserName: user.GetName(), UID: user.GetUID(),
Groups: user.GetGroups(), Extra: user.GetExtra(),
}
kc, _ := client.New(impCfg, client.Options{Scheme, Mapper: mgr.GetRESTMapper()})
reg := repo.NewRegistry(kc, defaultCache)
```

Every read performed while rendering or loading (chart sources, resource
editors, existing objects) is then evaluated by the kube-apiserver against the
caller's own RBAC. This requires `kube-ui-server`'s service account to hold
`impersonate` on `users`/`groups`/`userextras` (chart RBAC).

The shared mapper is reused (identity-independent); only the client's config
varies per caller.

## 5. Consumption (b3)

b3's 6 read-only handlers are rewritten to build the typed `EditorRender` /
`EditorTemplate` object and `Create` it against the target cluster via an
editor-scheme controller-runtime client (lib-helm's client scheme lacks these
types), then return the populated `Response`. Default output formats match b3's
existing behavior (model → JSON, manifest → string, resources → YAML). Apply and
delete handlers are untouched. `clusters.go` routes are unchanged (macaron
resolves handler params by type).

## 6. Cross-repo rollout

1. **resource-metadata** — types + `make gen` (PR #653).
2. **ui-server** — bump resource-metadata, vendor, registry + wiring.
3. **b3** — bump resource-metadata, vendor, rewrite the 6 read handlers.

ui-server and b3 pin resource-metadata to the editor-api commit until #653
merges, after which they move to the released version.

## 7. Notes / future work

- `meta.resourcemanifests` overlaps with `EditorTemplate(output=resources)`; kept
as-is for back-compat.
- A `format` field was intentionally omitted; resources default to YAML (b3's
default). b3 can convert for the rare `format=json` case.
- Apply/delete could later be added as authorized (impersonating) mutating
actions if the platform moves off the NATS TaskMgr flow.
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ require (
kmodules.xyz/go-containerregistry v0.0.15
kmodules.xyz/monitoring-agent-api v0.34.3
kmodules.xyz/offshoot-api v0.34.0
kmodules.xyz/resource-metadata v0.47.0
kmodules.xyz/resource-metadata v0.47.1-0.20260626135212-050db1fc9442
kmodules.xyz/resource-metrics v0.34.2
kmodules.xyz/resource-metrics/utils v0.34.0
kmodules.xyz/sets v0.29.0
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -1187,8 +1187,8 @@ kmodules.xyz/monitoring-agent-api v0.34.3 h1:8dCTSatkKbi9CTAQ3+u7OmoyUghLvKTqqcl
kmodules.xyz/monitoring-agent-api v0.34.3/go.mod h1:0WtVdliczkCmjA/QyB2ZriVwABSA8Fhzt01yMEukFLY=
kmodules.xyz/offshoot-api v0.34.0 h1:HnOOp8FrCjTWjtNApRDo6Ahe79tOlLrJmyye4xxO4Kk=
kmodules.xyz/offshoot-api v0.34.0/go.mod h1:F+B59yYw4CZJ4uD4xu6C+mMLzIXUtuH7E+SbDICl9jE=
kmodules.xyz/resource-metadata v0.47.0 h1:lBZrF+akK7xnwVtV5185W5HyhhFBx07ln4IzwzebFAI=
kmodules.xyz/resource-metadata v0.47.0/go.mod h1:ejz7IVjhqmj6VH8CVfprocJK/IM++OeunrfUp51ZrIw=
kmodules.xyz/resource-metadata v0.47.1-0.20260626135212-050db1fc9442 h1:v44U7gJ2L2LLTeF7wv1fHlsDdxmYNwHoN/qZMREU8d4=
kmodules.xyz/resource-metadata v0.47.1-0.20260626135212-050db1fc9442/go.mod h1:ejz7IVjhqmj6VH8CVfprocJK/IM++OeunrfUp51ZrIw=
kmodules.xyz/resource-metrics v0.34.2 h1:rwK/6pl56htEXAx+PErRnQESPprLL5EpeD0OFqFa6uk=
kmodules.xyz/resource-metrics v0.34.2/go.mod h1:R34IKtp5+NqcQz7AQJheBJK6Iem0LqrCbm/55Mn+ECQ=
kmodules.xyz/resource-metrics/utils v0.34.0 h1:IAwJNoXQZu7bNiWgbwEp+w26uBH1AScprKMA2HX+Bbg=
Expand Down
21 changes: 21 additions & 0 deletions pkg/apiserver/apiserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ import (
resourcesservicestorage "kubeops.dev/ui-server/pkg/registry/core/resourceservice"
resourcesummarystorage "kubeops.dev/ui-server/pkg/registry/core/resourcesummary"
coststorage "kubeops.dev/ui-server/pkg/registry/cost/reports"
editorrenderstorage "kubeops.dev/ui-server/pkg/registry/editor/render"
editortemplatestorage "kubeops.dev/ui-server/pkg/registry/editor/template"
audittokenreqstorage "kubeops.dev/ui-server/pkg/registry/identity/audittokenrequest"
clusteridstorage "kubeops.dev/ui-server/pkg/registry/identity/clusteridentity"
inboxtokenreqstorage "kubeops.dev/ui-server/pkg/registry/identity/inboxtokenrequest"
Expand Down Expand Up @@ -101,6 +103,8 @@ import (
promclient "kmodules.xyz/monitoring-agent-api/client"
rscoreinstall "kmodules.xyz/resource-metadata/apis/core/install"
rscoreapi "kmodules.xyz/resource-metadata/apis/core/v1alpha1"
editorinstall "kmodules.xyz/resource-metadata/apis/editor/install"
editorapi "kmodules.xyz/resource-metadata/apis/editor/v1alpha1"
identityinstall "kmodules.xyz/resource-metadata/apis/identity/install"
identityapi "kmodules.xyz/resource-metadata/apis/identity/v1alpha1"
mgmtinstall "kmodules.xyz/resource-metadata/apis/management/install"
Expand Down Expand Up @@ -133,6 +137,7 @@ func init() {
costinstall.Install(Scheme)
rsinstall.Install(Scheme)
uiinstall.Install(Scheme)
editorinstall.Install(Scheme)
rscoreinstall.Install(Scheme)
mgmtinstall.Install(Scheme)
crdinstall.Install(Scheme)
Expand Down Expand Up @@ -431,6 +436,22 @@ func (c completedConfig) New(ctx context.Context) (*UIServer, error) {
return nil, err
}
}
{
apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(editorapi.SchemeGroupVersion.Group, Scheme, metav1.ParameterCodec, Codecs)

// EditorRender and EditorTemplate are read-only render/load actions. Each
// request is served with a client that impersonates the caller, so reads
// performed while rendering or loading are authorized against the caller's
// own RBAC.
v1alpha1storage := map[string]rest.Storage{}
v1alpha1storage[editorapi.ResourceEditorRenders] = editorrenderstorage.NewStorage(cfg, Scheme, mgr.GetRESTMapper())
v1alpha1storage[editorapi.ResourceEditorTemplates] = editortemplatestorage.NewStorage(cfg, Scheme, mgr.GetRESTMapper())
apiGroupInfo.VersionedResourcesStorageMap["v1alpha1"] = v1alpha1storage

if err := s.GenericAPIServer.InstallAPIGroup(&apiGroupInfo); err != nil {
return nil, err
}
}
{
// Create metrics handler and fill the stores with metrics store
// containing Help and Type headers of metrics
Expand Down
144 changes: 144 additions & 0 deletions pkg/registry/editor/editorutil/util.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/*
Copyright AppsCode Inc. and Contributors

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package editorutil

import (
"context"
"sort"
"strings"

apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
apirequest "k8s.io/apiserver/pkg/endpoints/request"
"k8s.io/client-go/rest"
meta_util "kmodules.xyz/client-go/meta"
"kubepack.dev/lib-helm/pkg/repo"
"sigs.k8s.io/controller-runtime/pkg/client"
releasesapi "x-helm.dev/apimachinery/apis/releases/v1alpha1"
)

// defaultCache is the chart disk cache shared across requests.
var defaultCache = repo.DefaultDiskCache()

// CallerClient returns a controller-runtime client and a lib-helm chart registry
// that impersonate the API caller, so every read performed while rendering or
// loading an editor is authorized against the caller's own RBAC.
func CallerClient(ctx context.Context, cfg *rest.Config, scheme *runtime.Scheme, mapper meta.RESTMapper) (client.Client, repo.IRegistry, error) {
user, ok := apirequest.UserFrom(ctx)
if !ok {
return nil, nil, apierrors.NewBadRequest("missing user info in request context")
}

impCfg := rest.CopyConfig(cfg)
impCfg.Impersonate = rest.ImpersonationConfig{
UserName: user.GetName(),
UID: user.GetUID(),
Groups: user.GetGroups(),
Extra: user.GetExtra(),
}

kc, err := client.New(impCfg, client.Options{Scheme: scheme, Mapper: mapper})
if err != nil {
return nil, nil, err
}
return kc, repo.NewRegistry(kc, defaultCache), nil
}

// RenderedResourceOutput converts a rendered chart template's CRDs and resources
// into a releasesapi.ResourceOutput, mirroring b3 deploy.PreviewEditorResources:
// CRDs and resources are marshaled from their object Data, and resources are
// sorted so kubedb.com objects come first.
func RenderedResourceOutput(crds []releasesapi.BucketObject, resources []releasesapi.ResourceObject, skipCRDs bool, format meta_util.DataFormat) (*releasesapi.ResourceOutput, error) {
var out releasesapi.ResourceOutput

if !skipCRDs {
for _, crd := range crds {
data, err := meta_util.Marshal(crd.Data, format)
if err != nil {
return nil, err
}
out.CRDs = append(out.CRDs, releasesapi.ResourceFile{
Filename: crd.Filename + "." + string(format),
Data: string(data),
})
}
}

sortKubeDBFirst(resources)

for _, r := range resources {
data, err := meta_util.Marshal(r.Data, format)
if err != nil {
return nil, err
}
out.Resources = append(out.Resources, releasesapi.ResourceFile{
Filename: r.Filename + "." + string(format),
Key: r.Key,
Data: string(data),
})
}
return &out, nil
}

// LoadedResourceOutput converts the resources of a loaded editor template into a
// releasesapi.ResourceOutput, mirroring b3 deploy.LoadEditorResources (each
// ResourceObject is marshaled whole, no CRDs section, no reordering).
func LoadedResourceOutput(resources []releasesapi.ResourceObject, format meta_util.DataFormat) (*releasesapi.ResourceOutput, error) {
var out releasesapi.ResourceOutput
for _, r := range resources {
data, err := meta_util.Marshal(r, format)
if err != nil {
return nil, err
}
out.Resources = append(out.Resources, releasesapi.ResourceFile{
Filename: r.Filename + "." + string(format),
Key: r.Key,
Data: string(data),
})
}
return &out, nil
}

func sortKubeDBFirst(resources []releasesapi.ResourceObject) {
hasKubeDB := func(r releasesapi.ResourceObject) bool {
if r.Data == nil {
return false
}
apiVersion, found, err := unstructured.NestedString(r.Data.Object, "apiVersion")
if err != nil || !found {
return false
}
parts := strings.SplitN(apiVersion, "/", 2)
if len(parts) < 1 {
return false
}
return parts[0] == "kubedb.com"
}
sort.SliceStable(resources, func(i, j int) bool {
iIsKubeDB := hasKubeDB(resources[i])
jIsKubeDB := hasKubeDB(resources[j])
if iIsKubeDB && !jIsKubeDB {
return true
}
if !iIsKubeDB && jIsKubeDB {
return false
}
return i < j
})
}
Loading