Skip to content
Draft
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: 7 additions & 0 deletions apis/go.work
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
go 1.24.11

use (
./management/functions/xtenant
./pkg/composer
./pkg/resources
)
7 changes: 4 additions & 3 deletions apis/management/functions/xtenant/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ ARG GO_VERSION=1

FROM --platform=${BUILDPLATFORM} golang:${GO_VERSION} AS build

WORKDIR /src
WORKDIR /src/apis
ENV CGO_ENABLED=0

COPY apis/pkg/resources/ /src/apis/pkg/resources/
COPY apis/management/functions/xtenant/ /src/apis/management/functions/xtenant/
COPY apis/go.work ./
COPY apis/pkg/ ./pkg/
COPY apis/management/functions/xtenant/ ./management/functions/xtenant/

WORKDIR /src/apis/management/functions/xtenant

Expand Down
78 changes: 57 additions & 21 deletions apis/management/functions/xtenant/fn.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ package main
import (
"context"

"github.com/mdaops/cortex/configurations/pkg/composer"
"github.com/mdaops/cortex/configurations/pkg/resources"
rbacv1 "k8s.io/api/rbac/v1"

"github.com/crossplane/function-sdk-go/errors"
"github.com/crossplane/function-sdk-go/logging"
Expand All @@ -17,10 +19,55 @@ import (
// Function implements the Crossplane composition function for Tenant resources.
type Function struct {
fnv1.UnimplementedFunctionRunnerServiceServer

log logging.Logger
}

func composeBase(c *composer.Composer) error {
name := c.GetString("spec.name")
if err := c.Err(); err != nil {
return err
}

c.Add("namespace", resources.NewTenantNamespace(name))

project, err := resources.NewTenantProject(name, c.GetString("spec.description"), c.GetStringArray("spec.sourceRepos"))
c.ClearErrs()
if err != nil {
return errors.Wrap(err, "spec.sourceRepos is required")
}
c.Add("project", project)
return nil
}

func composeArgoWorkflows(c *composer.Composer) {
if !c.GetBool("spec.features.argoWorkflows.enabled") {
c.ClearErrs()
return
}
name := c.GetString("spec.name")
c.ClearErrs()

c.Add("argo-workflow-sa", resources.NewServiceAccount(resources.ServiceAccountConfig{
Name: "argo-workflow",
Namespace: name,
}))
c.Add("argo-workflow-role", resources.NewRole(resources.RoleConfig{
Name: "argo-workflow",
Namespace: name,
Rules: []rbacv1.PolicyRule{{
APIGroups: []string{"argoproj.io"},
Resources: []string{"workflowtaskresults"},
Verbs: []string{"create", "patch"},
}},
}))
c.Add("argo-workflow-rolebinding", resources.NewRoleBinding(resources.RoleBindingConfig{
Name: "argo-workflow",
Namespace: name,
RoleName: "argo-workflow",
ServiceAccountName: "argo-workflow",
}))
}

// RunFunction composes namespace and ArgoCD project resources for a Tenant.
func (f *Function) RunFunction(_ context.Context, req *fnv1.RunFunctionRequest) (*fnv1.RunFunctionResponse, error) {
rsp := response.To(req, response.DefaultTTL)
Expand All @@ -36,47 +83,36 @@ func (f *Function) RunFunction(_ context.Context, req *fnv1.RunFunctionRequest)
"xr-kind", oxr.Resource.GetKind(),
)

tenantName, err := oxr.Resource.GetString("spec.name")
if err != nil {
response.Fatal(rsp, errors.Wrap(err, "spec.name is required"))
return rsp, nil
}

description, _ := oxr.Resource.GetString("spec.description")
sourceRepos, _ := oxr.Resource.GetStringArray("spec.sourceRepos")
c := composer.New(oxr)

project, err := resources.NewTenantProject(tenantName, description, sourceRepos)
if err != nil {
response.Fatal(rsp, errors.Wrap(err, "spec.sourceRepos is required"))
if err := composeBase(c); err != nil {
response.Fatal(rsp, err)
return rsp, nil
}

desiredTyped := map[resource.Name]any{
"namespace": resources.NewTenantNamespace(tenantName),
"project": project,
}
composeArgoWorkflows(c)

desired, err := request.GetDesiredComposedResources(req)
if err != nil {
response.Fatal(rsp, errors.Wrap(err, "cannot get desired composed resources"))
return rsp, nil
}

for name, obj := range desiredTyped {
c := composed.New()
if err := resources.ConvertViaJSON(c, obj); err != nil {
for name, obj := range c.Desired() {
res := composed.New()
if err := resources.ConvertViaJSON(res, obj); err != nil {
response.Fatal(rsp, errors.Wrapf(err, "cannot convert %s to unstructured", name))
return rsp, nil
}
desired[name] = &resource.DesiredComposed{Resource: c}
desired[name] = &resource.DesiredComposed{Resource: res}
}

if err := response.SetDesiredComposedResources(rsp, desired); err != nil {
response.Fatal(rsp, errors.Wrap(err, "cannot set desired composed resources"))
return rsp, nil
}

log.Info("Composed tenant resources", "tenant", tenantName)
log.Info("Composed tenant resources", "tenant", c.GetString("spec.name"))
response.ConditionTrue(rsp, "FunctionSuccess", "Success").TargetCompositeAndClaim()

return rsp, nil
Expand Down
77 changes: 77 additions & 0 deletions apis/management/functions/xtenant/fn_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,83 @@ func TestRunFunction(t *testing.T) {
},
},
},
"ArgoWorkflowsEnabled": {
reason: "The function should create RBAC resources when argoWorkflows is enabled",
args: args{
req: &fnv1.RunFunctionRequest{
Observed: &fnv1.State{
Composite: &fnv1.Resource{
Resource: resource.MustStructJSON(`{
"apiVersion": "platform.synapse.io/v1alpha1",
"kind": "Tenant",
"metadata": {"name": "ml"},
"spec": {
"name": "ml",
"sourceRepos": ["https://github.com/org/ml-apps.git"],
"features": {
"argoWorkflows": {
"enabled": true
}
}
}
}`),
},
},
},
},
wantResourceCnt: 5,
validateFn: func(t *testing.T, rsp *fnv1.RunFunctionResponse) {
t.Helper()
resources := rsp.GetDesired().GetResources()

sa := resources["argo-workflow-sa"]
if sa == nil {
t.Fatal("expected argo-workflow-sa resource")
}
saData := structToMap(t, sa.GetResource())
assertEqual(t, "ml-argo-workflow-sa", getNestedString(saData, "metadata", "name"))

role := resources["argo-workflow-role"]
if role == nil {
t.Fatal("expected argo-workflow-role resource")
}
roleData := structToMap(t, role.GetResource())
assertEqual(t, "ml-argo-workflow-role", getNestedString(roleData, "metadata", "name"))

rb := resources["argo-workflow-rolebinding"]
if rb == nil {
t.Fatal("expected argo-workflow-rolebinding resource")
}
rbData := structToMap(t, rb.GetResource())
assertEqual(t, "ml-argo-workflow-rolebinding", getNestedString(rbData, "metadata", "name"))
},
},
"ArgoWorkflowsDisabled": {
reason: "The function should not create RBAC resources when argoWorkflows is disabled",
args: args{
req: &fnv1.RunFunctionRequest{
Observed: &fnv1.State{
Composite: &fnv1.Resource{
Resource: resource.MustStructJSON(`{
"apiVersion": "platform.synapse.io/v1alpha1",
"kind": "Tenant",
"metadata": {"name": "ml"},
"spec": {
"name": "ml",
"sourceRepos": ["https://github.com/org/ml-apps.git"],
"features": {
"argoWorkflows": {
"enabled": false
}
}
}
}`),
},
},
},
},
wantResourceCnt: 2,
},
}

for name, tc := range cases {
Expand Down
5 changes: 4 additions & 1 deletion apis/management/functions/xtenant/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,14 @@ require (
github.com/alecthomas/kong v1.13.0
github.com/crossplane/function-sdk-go v0.5.0
github.com/google/go-cmp v0.7.0
github.com/mdaops/cortex/configurations/pkg/composer v0.0.0
github.com/mdaops/cortex/configurations/pkg/resources v0.0.0
google.golang.org/protobuf v1.36.11
k8s.io/api v0.34.0
)

replace github.com/mdaops/cortex/configurations/pkg/composer => ../../../pkg/composer

replace github.com/mdaops/cortex/configurations/pkg/resources => ../../../pkg/resources

require (
Expand Down Expand Up @@ -74,7 +78,6 @@ require (
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
k8s.io/api v0.34.0 // indirect
k8s.io/apiextensions-apiserver v0.34.0 // indirect
k8s.io/apimachinery v0.34.3 // indirect
k8s.io/client-go v0.34.0 // indirect
Expand Down
12 changes: 12 additions & 0 deletions apis/management/package/apis/tenant/definition.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,18 @@ spec:
description: Git repositories the tenant can deploy from
items:
type: string
features:
type: object
description: Optional features to enable for this tenant
properties:
argoWorkflows:
type: object
description: Argo Workflows integration
properties:
enabled:
type: boolean
description: Enable Argo Workflows RBAC for this tenant
default: false
status:
type: object
properties:
Expand Down
95 changes: 95 additions & 0 deletions apis/pkg/composer/composer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package composer

import (
"errors"

"github.com/crossplane/function-sdk-go/resource"
)

var ErrPathNotFound = errors.New("path not found")

// PathError wraps an error with the path that caused it.
type PathError struct {
Path string
Err error
}

func (e *PathError) Error() string {
return "path " + e.Path + ": " + e.Err.Error()
}

func (e *PathError) Unwrap() error {
return e.Err
}

func (e *PathError) Is(target error) bool {
return target == ErrPathNotFound
}

// Composer accumulates desired resources and errors from an XR.
type Composer struct {
oxr *resource.Composite
desired map[resource.Name]any
errs []error
}

// New creates a Composer for the given observed composite resource.
func New(oxr *resource.Composite) *Composer {
return &Composer{
oxr: oxr,
desired: make(map[resource.Name]any),
}
}

// GetString returns the string at path, recording an error if not found.
func (c *Composer) GetString(path string) string {
val, err := c.oxr.Resource.GetString(path)
if err != nil {
c.errs = append(c.errs, &PathError{Path: path, Err: err})
return ""
}
return val
}

// GetStringArray returns the string array at path, recording an error if not found.
func (c *Composer) GetStringArray(path string) []string {
val, err := c.oxr.Resource.GetStringArray(path)
if err != nil {
c.errs = append(c.errs, &PathError{Path: path, Err: err})
return nil
}
return val
}

// GetBool returns the bool at path, recording an error if not found.
func (c *Composer) GetBool(path string) bool {
val, err := c.oxr.Resource.GetBool(path)
if err != nil {
c.errs = append(c.errs, &PathError{Path: path, Err: err})
return false
}
return val
}

// Add adds a resource to the desired set.
func (c *Composer) Add(name resource.Name, obj any) {
c.desired[name] = obj
}

// Desired returns the accumulated desired resources.
func (c *Composer) Desired() map[resource.Name]any {
return c.desired
}

// Err returns accumulated errors, or nil if none.
func (c *Composer) Err() error {
if len(c.errs) == 0 {
return nil
}
return errors.Join(c.errs...)
}

// ClearErrs clears accumulated errors.
func (c *Composer) ClearErrs() {
c.errs = nil
}
Loading