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
7 changes: 5 additions & 2 deletions providers/infrastructure/backend/kro/e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,7 @@ const (
// e2eMinimalSpecs supplies a valid instance spec for templates that ship no
// sampleValues (those that do — application, database — use them directly).
var e2eMinimalSpecs = map[string]map[string]any{
"redis-cache": {"name": "e2e-redis"},
"simple-webapp": {"name": "e2e-web"},
"redis-cache": {"name": "e2e-redis"},
}
Comment on lines 82 to 86

// e2ePlatformStamped are spec fields a platform component normally writes onto an
Expand All @@ -100,6 +99,10 @@ var e2ePlatformStamped = map[string]map[string]any{
"expose": map[string]any{"fqdn": "demo.e2e.test"},
"credentialsSecretName": "demo-oidc",
},
// simple-webapp is exposure-only: the controller stamps just expose.fqdn.
"simple-webapp": {
"expose": map[string]any{"fqdn": "web.e2e.test"},
},
}

// e2eApplyErrorMarkers are substrings kro puts in an instance condition when it
Expand Down
50 changes: 50 additions & 0 deletions providers/infrastructure/backend/kro/seedtemplates_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,56 @@ func TestSeedTemplatesIncludeStandaloneDatabase(t *testing.T) {
}
}

// TestSeedTemplatesSimpleWebappIsDevelopmentCapable pins the simple-webapp
// contract App Studio depends on: a single dev component ("app") claiming the
// workspace root, the synthesized dev overlay in the RGD, public exposure via
// HTTPRoute, and the status fields the preview (status.url) and data plane
// (controlSecretRef, components) resolve through.
func TestSeedTemplatesSimpleWebappIsDevelopmentCapable(t *testing.T) {
raw, err := os.ReadFile(filepath.Join("..", "..", "install", "templates", "simple-webapp.yaml"))
if err != nil {
t.Fatalf("read simple-webapp seed template: %v", err)
}
tmpl := decodeTemplate(t, raw)
if got, want := tmpl.Spec.InstanceCRD.Kind, "SimpleWebApp"; got != want {
t.Fatalf("instance kind = %q, want %q", got, want)
}
if tmpl.Spec.Development == nil {
t.Fatal("simple-webapp declares no spec.development — it cannot back an App Studio project")
}
comp, ok := tmpl.Spec.Development.Components["app"]
if !ok {
t.Fatalf("development components = %v, want key %q", tmpl.Spec.Development.Components, "app")
}
if got, want := comp.WorkspacePath, "."; got != want {
t.Fatalf("app component workspacePath = %q, want %q (whole workspace)", got, want)
}
if tmpl.Spec.DataPlane == nil {
t.Fatal("simple-webapp declares no dataPlane — sync/log/restart verbs would 404")
}
if _, ok := tmpl.Spec.DataPlane.Components["app"]; !ok {
t.Fatal("simple-webapp declares no dataPlane component for app — sync/log/restart verbs would 404")
}

rgd, err := buildRGD(tmpl, testTokens())
if err != nil {
t.Fatalf("buildRGD(simple-webapp): %v", err)
}
for _, id := range []string{"appDeployment", "appService", "httpRoute", "appDevDeployment", "appDevWorkspace", "appDevControlService", "kedgeDevControlSecret"} {
if findResource(t, rgd, id) == nil {
t.Fatalf("simple-webapp RGD missing %s resource", id)
}
}
if _, found, _ := unstructured.NestedFieldNoCopy(rgd.Object, "spec", "schema", "spec", "kedgeMode"); !found {
t.Fatal("simple-webapp RGD schema missing kedgeMode (dev overlay not applied)")
}
for _, field := range []string{"url", "host", "ready", "runtimeNamespace", "controlSecretRef", "components"} {
if _, found, _ := unstructured.NestedFieldNoCopy(rgd.Object, "spec", "schema", "status", field); !found {
t.Fatalf("simple-webapp status missing %s", field)
}
}
}

func TestSeedTemplatesDoNotExposeStandaloneSandboxPreviewHTTPRoute(t *testing.T) {
path := filepath.Join("..", "..", "install", "templates", "sandbox-preview-httproute.yaml")
if _, err := os.Stat(path); !os.IsNotExist(err) {
Expand Down
125 changes: 88 additions & 37 deletions providers/infrastructure/controller/application/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,29 @@
//
// http://www.apache.org/licenses/LICENSE-2.0

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

import (
Expand Down Expand Up @@ -60,10 +63,31 @@ import (
"github.com/faroshq/provider-infrastructure/kro"
)

// appGVK is read with unstructured so the controller doesn't depend on a
// generated client for the per-template CRD (its schema is authored at runtime
// by the Template controller).
var appGVK = schema.GroupVersionKind{Group: "infrastructure.kedge.faros.sh", Version: "v1alpha1", Kind: "Application"}
// Instance kinds are read with unstructured so the controller doesn't depend
// on generated clients for the per-template CRDs (their schemas are authored
// at runtime by the Template controller).
var (
appGVK = schema.GroupVersionKind{Group: "infrastructure.kedge.faros.sh", Version: "v1alpha1", Kind: "Application"}
webappGVK = schema.GroupVersionKind{Group: "infrastructure.kedge.faros.sh", Version: "v1alpha1", Kind: "SimpleWebApp"}
)

// instanceKind pairs an exposed instance GVK with the treatment it needs.
// oidc kinds get the credentialsSecretName stamp, the OIDC secret bridge, and
// the finalizer guarding that cross-cluster Secret; exposure-only kinds get
// just the fqdn stamp.
type instanceKind struct {
name string // controller name, unique per kind
gvk schema.GroupVersionKind
oidc bool
}

// instanceKinds is every template instance kind the controller reconciles.
// A new exposed template (spec.expose + HTTPRoute in its graph) is one line
// here — oidc only when its graph runs an oauth2-proxy gate.
var instanceKinds = []instanceKind{
{name: "infra-application", gvk: appGVK, oidc: true},
{name: "infra-simplewebapp", gvk: webappGVK, oidc: false},
}

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

app := &unstructured.Unstructured{}
app.SetGroupVersionKind(appGVK)
if err := mcbuilder.ControllerManagedBy(mgr).
Named("infra-application").
For(app).
Complete(c); err != nil {
return nil, fmt.Errorf("registering application reconciler: %w", err)
for _, k := range instanceKinds {
obj := &unstructured.Unstructured{}
obj.SetGroupVersionKind(k.gvk)
if err := mcbuilder.ControllerManagedBy(mgr).
Named(k.name).
For(obj).
Complete(&instanceReconciler{c: c, kind: k}); err != nil {
return nil, fmt.Errorf("registering %s reconciler: %w", k.gvk.Kind, err)
}
}

c.mgr = mgr
return c, nil
}

// instanceReconciler reconciles one instance kind; the shared Controller
// carries the config and cross-kind helpers.
type instanceReconciler struct {
c *Controller
kind instanceKind
}

// Start runs the multicluster manager (blocking).
func (c *Controller) Start(ctx context.Context) error { return c.mgr.Start(ctx) }

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

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

app := &unstructured.Unstructured{}
app.SetGroupVersionKind(appGVK)
app.SetGroupVersionKind(r.kind.gvk)
if err := tenantClient.Get(ctx, req.NamespacedName, app); err != nil {
if apierrors.IsNotFound(err) {
return ctrl.Result{}, nil
}
return ctrl.Result{}, err
}

// Exposure-only kinds: stamp the fqdn and stop — no cross-cluster state,
// no finalizer, nothing to clean up on deletion.
if !r.kind.oidc {
if !app.GetDeletionTimestamp().IsZero() {
return ctrl.Result{}, nil
}
return ctrl.Result{}, c.stampSpec(ctx, tenantClient, tenant, app, false)
}

// Deletion: clean up the cross-cluster bridged Secret, then drop the
// finalizer so the instance can be removed.
if !app.GetDeletionTimestamp().IsZero() {

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.

metav1.Time.IsZero() has a pointer receiver with an explicit nil check ("IsZero returns true if the value is nil or time is zero"), so a nil deletion timestamp cannot panic here — the same unguarded idiom is used a few lines below on the Application path (and was there before this PR). The redundant != nil && that likely prompted this was simplified to match.

Expand All @@ -224,7 +268,7 @@ func (c *Controller) Reconcile(ctx context.Context, req mcreconcile.Request) (ct
}

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

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

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

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

if curFQDN == fqdn && curSecret == wantSecret {
current := curFQDN == fqdn
if withCredentials {
current = current && nestedString(app, "spec", "credentialsSecretName") == kro.CredentialsSecretName(app.GetName())
}
if current {
return nil
}
if err := unstructured.SetNestedField(app.Object, fqdn, "spec", "expose", "fqdn"); err != nil {
return fmt.Errorf("set spec.expose.fqdn: %w", err)
}
if err := unstructured.SetNestedField(app.Object, wantSecret, "spec", "credentialsSecretName"); err != nil {
return fmt.Errorf("set spec.credentialsSecretName: %w", err)
if withCredentials {
if err := unstructured.SetNestedField(app.Object, kro.CredentialsSecretName(app.GetName()), "spec", "credentialsSecretName"); err != nil {
return fmt.Errorf("set spec.credentialsSecretName: %w", err)
}
}
if err := tenantClient.Update(ctx, app); err != nil {
return fmt.Errorf("stamping spec: %w", err)
Expand Down
2 changes: 1 addition & 1 deletion providers/infrastructure/docs/template-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ invalid field into a materialized resource.

| Kind | How to express it | Example |
|---|---|---|
| **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` |
| **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` |
| **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` |
| **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) |

Expand Down
3 changes: 3 additions & 0 deletions providers/infrastructure/install/templates/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ spec:
Deploys a 3-tier web app on ONE public URL: UI at `/`, API at `/api/*`, and a
managed PostgreSQL database. Choose this to ship a containerized web app with
a relational DB behind a single endpoint when you can supply both images.
For a frontend-only or single-container app with no API tier and no database
(a static site, an SPA, a self-contained server), prefer the simple-webapp
template — it is also development-capable and carries none of this weight.

YOU SUPPLY exactly two container images — frontendImage and backendImage —
and nothing else app-side. The platform provisions Postgres, injects the DB
Expand Down
23 changes: 23 additions & 0 deletions providers/infrastructure/install/templates/redis-cache.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,29 @@ spec:
category: Databases
version: 0.1.0
backend: kro
sampleValues:
name: cache
size: small
# Operational guidance for AI agents discovering this template over MCP —
# same convention as the database template.
agent:
usage: |
Provisions only a Redis instance: a credentials Secret, password
generator Job, StatefulSet, and ClusterIP Service. Choose this when an
app already has a runtime or development environment and needs a
standalone cache or ephemeral key-value store, without deploying a new
web app stack.

Consumers read the full connection URI from Secret
`<name>-credentials`, key `uri`. The URI has the form
`redis://:<password>@<name>:6379`. Redis may not be ready the moment a
consuming app starts — retry the initial connection. This template does
not expose Redis publicly, and persistent=false (the default) means data
does not survive a restart.
outputs:
- "status.host / status.port — the in-cluster Service endpoint for Redis."
- "status.ready — ready replica count for the Redis StatefulSet."
- "status.connectionSecretRef — Secret '<name>-credentials' containing host, port, password, and uri keys. Secret values are not surfaced in status."
instanceCRD:
group: infrastructure.kedge.faros.sh
version: v1alpha1
Expand Down
Loading
Loading