Skip to content

Commit faca32e

Browse files
mjudeikisclaude
andcommitted
feat(infrastructure): development-capable simple-webapp template
The template catalog had exactly one development-capable template (application: frontend + backend + Postgres), so App Studio projects that only need a single-container web app — a static site, an SPA — were forced onto the full 3-tier stack or dead-ended in the assistant's template interview. simple-webapp itself was vestigial: no public URL, no development block, predating the Gateway API exposure work and the template-native sandbox design. Rework simple-webapp (v0.2.0) into the single-tier counterpart of the application template: - Public exposure: HTTPRoute on the platform Gateway parent, controller stamped expose.fqdn, status.url/host — the wiring App Studio's preview reads. - spec.development: one component "app" claiming the workspace root (workspacePath "."), node dev image, the proven vite shim start command carried verbatim from the application frontend tier. - spec.dataPlane: sync/restart/env/log verbs for that component. - image required in production only (same CEL pattern as application); a dev-only project provisions with just {name, kedgeMode}. - agent.usage guidance + sampleValues + portal view; drop serviceType. Generalize the Application instance controller into a table of exposed instance kinds: Application keeps the full treatment (fqdn + credentialsSecretName + OIDC bridge + finalizer); SimpleWebApp gets an exposure-only reconciler that stamps just spec.expose.fqdn — no cross-cluster state, no finalizer. A future exposed template is one line in instanceKinds. Also: cross-point application's agent guidance at simple-webapp for frontend-only apps, add the missing agent guidance block to redis-cache, and pin the simple-webapp development contract in a seed template test (dev component, workspace-root claim, overlay resources in the RGD, status fields the preview and data plane resolve through). No app-studio changes needed — it reads development.components live from the catalog, so simple-webapp appears in the portal picker and the assistant's selectable set automatically. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 16b21ab commit faca32e

6 files changed

Lines changed: 363 additions & 59 deletions

File tree

providers/infrastructure/backend/kro/seedtemplates_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,56 @@ func TestSeedTemplatesIncludeStandaloneDatabase(t *testing.T) {
115115
}
116116
}
117117

118+
// TestSeedTemplatesSimpleWebappIsDevelopmentCapable pins the simple-webapp
119+
// contract App Studio depends on: a single dev component ("app") claiming the
120+
// workspace root, the synthesized dev overlay in the RGD, public exposure via
121+
// HTTPRoute, and the status fields the preview (status.url) and data plane
122+
// (controlSecretRef, components) resolve through.
123+
func TestSeedTemplatesSimpleWebappIsDevelopmentCapable(t *testing.T) {
124+
raw, err := os.ReadFile(filepath.Join("..", "..", "install", "templates", "simple-webapp.yaml"))
125+
if err != nil {
126+
t.Fatalf("read simple-webapp seed template: %v", err)
127+
}
128+
tmpl := decodeTemplate(t, raw)
129+
if got, want := tmpl.Spec.InstanceCRD.Kind, "SimpleWebApp"; got != want {
130+
t.Fatalf("instance kind = %q, want %q", got, want)
131+
}
132+
if tmpl.Spec.Development == nil {
133+
t.Fatal("simple-webapp declares no spec.development — it cannot back an App Studio project")
134+
}
135+
comp, ok := tmpl.Spec.Development.Components["app"]
136+
if !ok {
137+
t.Fatalf("development components = %v, want key %q", tmpl.Spec.Development.Components, "app")
138+
}
139+
if got, want := comp.WorkspacePath, "."; got != want {
140+
t.Fatalf("app component workspacePath = %q, want %q (whole workspace)", got, want)
141+
}
142+
if tmpl.Spec.DataPlane == nil {
143+
t.Fatal("simple-webapp declares no dataPlane — sync/log/restart verbs would 404")
144+
}
145+
if _, ok := tmpl.Spec.DataPlane.Components["app"]; !ok {
146+
t.Fatal("simple-webapp declares no dataPlane component for app — sync/log/restart verbs would 404")
147+
}
148+
149+
rgd, err := buildRGD(tmpl, testTokens())
150+
if err != nil {
151+
t.Fatalf("buildRGD(simple-webapp): %v", err)
152+
}
153+
for _, id := range []string{"appDeployment", "appService", "httpRoute", "appDevDeployment", "appDevWorkspace", "appDevControlService", "kedgeDevControlSecret"} {
154+
if findResource(t, rgd, id) == nil {
155+
t.Fatalf("simple-webapp RGD missing %s resource", id)
156+
}
157+
}
158+
if _, found, _ := unstructured.NestedFieldNoCopy(rgd.Object, "spec", "schema", "spec", "kedgeMode"); !found {
159+
t.Fatal("simple-webapp RGD schema missing kedgeMode (dev overlay not applied)")
160+
}
161+
for _, field := range []string{"url", "host", "ready", "runtimeNamespace", "controlSecretRef", "components"} {
162+
if _, found, _ := unstructured.NestedFieldNoCopy(rgd.Object, "spec", "schema", "status", field); !found {
163+
t.Fatalf("simple-webapp status missing %s", field)
164+
}
165+
}
166+
}
167+
118168
func TestSeedTemplatesDoNotExposeStandaloneSandboxPreviewHTTPRoute(t *testing.T) {
119169
path := filepath.Join("..", "..", "install", "templates", "sandbox-preview-httproute.yaml")
120170
if _, err := os.Stat(path); !os.IsNotExist(err) {

providers/infrastructure/controller/application/controller.go

Lines changed: 88 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -6,26 +6,29 @@
66
//
77
// http://www.apache.org/licenses/LICENSE-2.0
88

9-
// Package application reconciles Application instances across every tenant
10-
// workspace that enabled the infrastructure provider, through the provider's
11-
// APIExport virtual workspace (the same cross-tenant rail the kuery provider's
12-
// engagement controller uses).
9+
// Package application reconciles exposed template instances across every
10+
// tenant workspace that enabled the infrastructure provider, through the
11+
// provider's APIExport virtual workspace (the same cross-tenant rail the
12+
// kuery provider's engagement controller uses).
1313
//
14-
// The kro fork materializes an Application's workloads on the runtime cluster
14+
// The kro fork materializes an instance's workloads on the runtime cluster
1515
// from the RGD, but two things the RGD can't produce itself need a controller:
1616
//
1717
// - spec.expose.fqdn — the public hostname, <prefix|name>-<tenantHash>.<base>.
1818
// kro can't derive a tenant hash in-graph, so the controller stamps it onto
1919
// spec; the RGD then reads ${schema.spec.expose.fqdn} for the HTTPRoute
20-
// hostname and the oauth2-proxy redirect URL.
21-
// - the OIDC client secret — it must land as a Secret beside the oauth2-proxy
22-
// pod on the runtime cluster WITHOUT sitting in the CR spec in clear text.
23-
// The controller bridges it into cloud-credentials-<name> in the per-tenant
24-
// runtime namespace (BYO: read from the tenant's cloud-credentials Secret;
25-
// Platform SSO: minted via Dex — wired in a follow-up).
20+
// hostname (and, on Application, the oauth2-proxy redirect URL). Every
21+
// exposed instance kind (Application, SimpleWebApp) needs this.
22+
// - the OIDC client secret (Application only) — it must land as a Secret
23+
// beside the oauth2-proxy pod on the runtime cluster WITHOUT sitting in
24+
// the CR spec in clear text. The controller bridges it into
25+
// cloud-credentials-<name> in the per-tenant runtime namespace (BYO: read
26+
// from the tenant's cloud-credentials Secret; Platform SSO: minted via
27+
// Dex — wired in a follow-up).
2628
//
2729
// Cleanup is finalizer-driven: the bridged Secret lives on a different cluster
28-
// than the instance, so cross-cluster ownerRefs don't apply.
30+
// than the instance, so cross-cluster ownerRefs don't apply. Exposure-only
31+
// kinds create no cross-cluster state and carry no finalizer.
2932
package application
3033

3134
import (
@@ -60,10 +63,31 @@ import (
6063
"github.com/faroshq/provider-infrastructure/kro"
6164
)
6265

63-
// appGVK is read with unstructured so the controller doesn't depend on a
64-
// generated client for the per-template CRD (its schema is authored at runtime
65-
// by the Template controller).
66-
var appGVK = schema.GroupVersionKind{Group: "infrastructure.kedge.faros.sh", Version: "v1alpha1", Kind: "Application"}
66+
// Instance kinds are read with unstructured so the controller doesn't depend
67+
// on generated clients for the per-template CRDs (their schemas are authored
68+
// at runtime by the Template controller).
69+
var (
70+
appGVK = schema.GroupVersionKind{Group: "infrastructure.kedge.faros.sh", Version: "v1alpha1", Kind: "Application"}
71+
webappGVK = schema.GroupVersionKind{Group: "infrastructure.kedge.faros.sh", Version: "v1alpha1", Kind: "SimpleWebApp"}
72+
)
73+
74+
// instanceKind pairs an exposed instance GVK with the treatment it needs.
75+
// oidc kinds get the credentialsSecretName stamp, the OIDC secret bridge, and
76+
// the finalizer guarding that cross-cluster Secret; exposure-only kinds get
77+
// just the fqdn stamp.
78+
type instanceKind struct {
79+
name string // controller name, unique per kind
80+
gvk schema.GroupVersionKind
81+
oidc bool
82+
}
83+
84+
// instanceKinds is every template instance kind the controller reconciles.
85+
// A new exposed template (spec.expose + HTTPRoute in its graph) is one line
86+
// here — oidc only when its graph runs an oauth2-proxy gate.
87+
var instanceKinds = []instanceKind{
88+
{name: "infra-application", gvk: appGVK, oidc: true},
89+
{name: "infra-simplewebapp", gvk: webappGVK, oidc: false},
90+
}
6791

6892
// secretGVK is used to Get/Create Secrets via the controller-runtime client
6993
// (tenant side) and shape the bridged Secret (runtime side).
@@ -162,27 +186,38 @@ func New(cfg Config) (*Controller, error) {
162186
return nil, fmt.Errorf("creating multicluster manager: %w", err)
163187
}
164188

165-
app := &unstructured.Unstructured{}
166-
app.SetGroupVersionKind(appGVK)
167-
if err := mcbuilder.ControllerManagedBy(mgr).
168-
Named("infra-application").
169-
For(app).
170-
Complete(c); err != nil {
171-
return nil, fmt.Errorf("registering application reconciler: %w", err)
189+
for _, k := range instanceKinds {
190+
obj := &unstructured.Unstructured{}
191+
obj.SetGroupVersionKind(k.gvk)
192+
if err := mcbuilder.ControllerManagedBy(mgr).
193+
Named(k.name).
194+
For(obj).
195+
Complete(&instanceReconciler{c: c, kind: k}); err != nil {
196+
return nil, fmt.Errorf("registering %s reconciler: %w", k.gvk.Kind, err)
197+
}
172198
}
173199

174200
c.mgr = mgr
175201
return c, nil
176202
}
177203

204+
// instanceReconciler reconciles one instance kind; the shared Controller
205+
// carries the config and cross-kind helpers.
206+
type instanceReconciler struct {
207+
c *Controller
208+
kind instanceKind
209+
}
210+
178211
// Start runs the multicluster manager (blocking).
179212
func (c *Controller) Start(ctx context.Context) error { return c.mgr.Start(ctx) }
180213

181-
// Reconcile stamps the computed fqdn + credentialsSecretName onto the
182-
// instance and bridges the OIDC client secret onto the runtime cluster.
183-
func (c *Controller) Reconcile(ctx context.Context, req mcreconcile.Request) (ctrl.Result, error) {
214+
// Reconcile stamps the computed fqdn (and, for oidc kinds,
215+
// credentialsSecretName) onto the instance and bridges the OIDC client secret
216+
// onto the runtime cluster.
217+
func (r *instanceReconciler) Reconcile(ctx context.Context, req mcreconcile.Request) (ctrl.Result, error) {
218+
c := r.c
184219
tenant := string(req.ClusterName)
185-
log := klog.FromContext(ctx).WithValues("cluster", tenant, "application", req.Name)
220+
log := klog.FromContext(ctx).WithValues("cluster", tenant, "kind", r.kind.gvk.Kind, "instance", req.Name)
186221

187222
cl, err := c.mgr.GetCluster(ctx, req.ClusterName)
188223
if err != nil {
@@ -191,14 +226,23 @@ func (c *Controller) Reconcile(ctx context.Context, req mcreconcile.Request) (ct
191226
tenantClient := cl.GetClient()
192227

193228
app := &unstructured.Unstructured{}
194-
app.SetGroupVersionKind(appGVK)
229+
app.SetGroupVersionKind(r.kind.gvk)
195230
if err := tenantClient.Get(ctx, req.NamespacedName, app); err != nil {
196231
if apierrors.IsNotFound(err) {
197232
return ctrl.Result{}, nil
198233
}
199234
return ctrl.Result{}, err
200235
}
201236

237+
// Exposure-only kinds: stamp the fqdn and stop — no cross-cluster state,
238+
// no finalizer, nothing to clean up on deletion.
239+
if !r.kind.oidc {
240+
if app.GetDeletionTimestamp() != nil && !app.GetDeletionTimestamp().IsZero() {
241+
return ctrl.Result{}, nil
242+
}
243+
return ctrl.Result{}, c.stampSpec(ctx, tenantClient, tenant, app, false)
244+
}
245+
202246
// Deletion: clean up the cross-cluster bridged Secret, then drop the
203247
// finalizer so the instance can be removed.
204248
if !app.GetDeletionTimestamp().IsZero() {
@@ -224,7 +268,7 @@ func (c *Controller) Reconcile(ctx context.Context, req mcreconcile.Request) (ct
224268
}
225269

226270
// 1. Stamp spec.expose.fqdn + spec.credentialsSecretName (idempotent).
227-
if err := c.stampSpec(ctx, tenantClient, tenant, app); err != nil {
271+
if err := c.stampSpec(ctx, tenantClient, tenant, app, true); err != nil {
228272
return ctrl.Result{}, err
229273
}
230274

@@ -268,27 +312,34 @@ func (c *Controller) Reconcile(ctx context.Context, req mcreconcile.Request) (ct
268312
return ctrl.Result{}, nil
269313
}
270314

271-
// stampSpec computes the fqdn + bridged-Secret name and writes them onto the
272-
// instance spec if not already set. Idempotent: a no-op once both are stamped.
273-
func (c *Controller) stampSpec(ctx context.Context, tenantClient client.Client, tenant string, app *unstructured.Unstructured) error {
315+
// stampSpec computes the fqdn (and, when withCredentials, the bridged-Secret
316+
// name) and writes them onto the instance spec if not already set. Idempotent:
317+
// a no-op once everything is stamped. Exposure-only kinds don't declare
318+
// credentialsSecretName in their schema, so stamping it would be pruned —
319+
// they stamp only the fqdn.
320+
func (c *Controller) stampSpec(ctx context.Context, tenantClient client.Client, tenant string, app *unstructured.Unstructured, withCredentials bool) error {
274321
prefix := nestedString(app, "spec", "expose", "hostnamePrefix")
275322
curFQDN := nestedString(app, "spec", "expose", "fqdn")
276-
curSecret := nestedString(app, "spec", "credentialsSecretName")
277323

278324
fqdn, err := apps.Host(prefix, app.GetName(), tenant, c.cfg.BaseDomain)
279325
if err != nil {
280326
return fmt.Errorf("computing fqdn: %w", err)
281327
}
282-
wantSecret := kro.CredentialsSecretName(app.GetName())
283328

284-
if curFQDN == fqdn && curSecret == wantSecret {
329+
current := curFQDN == fqdn
330+
if withCredentials {
331+
current = current && nestedString(app, "spec", "credentialsSecretName") == kro.CredentialsSecretName(app.GetName())
332+
}
333+
if current {
285334
return nil
286335
}
287336
if err := unstructured.SetNestedField(app.Object, fqdn, "spec", "expose", "fqdn"); err != nil {
288337
return fmt.Errorf("set spec.expose.fqdn: %w", err)
289338
}
290-
if err := unstructured.SetNestedField(app.Object, wantSecret, "spec", "credentialsSecretName"); err != nil {
291-
return fmt.Errorf("set spec.credentialsSecretName: %w", err)
339+
if withCredentials {
340+
if err := unstructured.SetNestedField(app.Object, kro.CredentialsSecretName(app.GetName()), "spec", "credentialsSecretName"); err != nil {
341+
return fmt.Errorf("set spec.credentialsSecretName: %w", err)
342+
}
292343
}
293344
if err := tenantClient.Update(ctx, app); err != nil {
294345
return fmt.Errorf("stamping spec: %w", err)

providers/infrastructure/docs/template-conventions.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ invalid field into a materialized resource.
1515

1616
| Kind | How to express it | Example |
1717
|---|---|---|
18-
| **Per-instance, configurable** (image, version, size, replicas) | `spec.schema` field **with a `default`**; the resource references `${schema.spec.<field>}` | `simple-webapp.spec.image` (`default: "nginx:latest"`); `postgres-database.spec.version` |
18+
| **Per-instance, configurable** (image, version, size, replicas) | `spec.schema` field **with a `default`**; the resource references `${schema.spec.<field>}` | `simple-webapp.spec.port` (`default: 8080`); `postgres-database.spec.version` |
1919
| **Fixed sidecar / tooling image** (not user-facing) | **hardcoded literal** in the resource | the control-token `bitnami/kubectl` job (database, redis, application); `quay.io/oauth2-proxy/oauth2-proxy:v7.6.0` |
2020
| **Platform-global, no universal default** | a reserved `${kedge.*}` substitution token, resolved by the kro backend from env | the exposure Gateway parent `${kedge.gatewayName}` / `${kedge.gatewayNamespace}`; the dev-overlay images `${kedge.devImage.<toolchain>}` / `${kedge.devAgentImage}`; the exposure-URL port suffix `${kedge.appPublicPort}` (empty in prod, `:10443` on local kind) |
2121

providers/infrastructure/install/templates/application.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ spec:
2929
Deploys a 3-tier web app on ONE public URL: UI at `/`, API at `/api/*`, and a
3030
managed PostgreSQL database. Choose this to ship a containerized web app with
3131
a relational DB behind a single endpoint when you can supply both images.
32+
For a frontend-only or single-container app with no API tier and no database
33+
(a static site, an SPA, a self-contained server), prefer the simple-webapp
34+
template — it is also development-capable and carries none of this weight.
3235
3336
YOU SUPPLY exactly two container images — frontendImage and backendImage —
3437
and nothing else app-side. The platform provisions Postgres, injects the DB

providers/infrastructure/install/templates/redis-cache.yaml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,29 @@ spec:
88
category: Databases
99
version: 0.1.0
1010
backend: kro
11+
sampleValues:
12+
name: cache
13+
size: small
14+
# Operational guidance for AI agents discovering this template over MCP —
15+
# same convention as the database template.
16+
agent:
17+
usage: |
18+
Provisions only a Redis instance: a credentials Secret, password
19+
generator Job, StatefulSet, and ClusterIP Service. Choose this when an
20+
app already has a runtime or development environment and needs a
21+
standalone cache or ephemeral key-value store, without deploying a new
22+
web app stack.
23+
24+
Consumers read the full connection URI from Secret
25+
`<name>-credentials`, key `uri`. The URI has the form
26+
`redis://:<password>@<name>:6379`. Redis may not be ready the moment a
27+
consuming app starts — retry the initial connection. This template does
28+
not expose Redis publicly, and persistent=false (the default) means data
29+
does not survive a restart.
30+
outputs:
31+
- "status.host / status.port — the in-cluster Service endpoint for Redis."
32+
- "status.ready — ready replica count for the Redis StatefulSet."
33+
- "status.connectionSecretRef — Secret '<name>-credentials' containing host, port, password, and uri keys. Secret values are not surfaced in status."
1134
instanceCRD:
1235
group: infrastructure.kedge.faros.sh
1336
version: v1alpha1

0 commit comments

Comments
 (0)