Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Generalize Ingress creation, use wildcard cert with OCP apps domain #305

Open
wants to merge 15 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 12 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
264 changes: 264 additions & 0 deletions pkg/comp-functions/functions/common/ingress.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
package common

import (
"fmt"
"strings"

"github.com/vshn/appcat/v4/pkg/comp-functions/runtime"
"gopkg.in/yaml.v2"
netv1 "k8s.io/api/networking/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/utils/ptr"
)

// IngressConfig contains general information for generating an Ingress obect
type IngressConfig struct {
AdditionalAnnotations map[string]string // Optional
AdditionalIngressNames []string // Optional
FQDNs []string
ServiceConfig IngressRuleConfig
TlsCertBaseName string
}

// IngressRuleConfig describes an ingress rule configuration
type IngressRuleConfig struct {
RelPath string // Optional, defaults to "/"
ServiceNameSuffix string // Optional
ServicePortName string // Has preference over ServicePortNumber
ServicePortNumber int32
}

// Checks if an FQDN is part of a reference FQDN, e.g. an OpenShift Apps domain; "*nextcloud*.apps.cluster.com".
// Returns true if yes and FQDN is not a 2nd level subdomain (i.e. *sub2.sub1*.apps.cluster.com)
func IsSingleSubdomainOfRefDomain(fqdn string, reference string) bool {
if !strings.Contains(fqdn, reference) || reference == "" {
return false
}

noSuffix, _ := strings.CutSuffix(fqdn, reference)
return len(strings.Split(noSuffix, ".")) == 2 // Handles prefixed dot of reference domain
}

// Obtain ingress annotations and optionally extend them using additionalAnnotations
func getIngressAnnotations(svc *runtime.ServiceRuntime, additionalAnnotations map[string]string) map[string]string {
annotations := map[string]string{}
if svc.Config.Data["ingress_annotations"] != "" {
err := yaml.Unmarshal([]byte(svc.Config.Data["ingress_annotations"]), annotations)
if err != nil {
svc.Log.Error(err, "cannot unmarshal ingress annotations from input")
svc.AddResult(runtime.NewWarningResult(fmt.Sprintf("cannot unmarshal ingress annotations from input: %s", err)))
}
} else {
svc.Log.Info("no ingress annotations are defined")
}

for k, v := range additionalAnnotations {
annotations[k] = v
}

return annotations
}

// Creates ingress rules based on a single service name and port. svcNameSuffix is optional and gets appended.
// Will use svcPortName over svcPortNumber (if specified)
func createIngressRule(comp InfoGetter, fqdns []string, ruleConfig IngressRuleConfig) []netv1.IngressRule {
svcNameSuffix := ruleConfig.ServiceNameSuffix
if !strings.HasPrefix(svcNameSuffix, "-") && len(svcNameSuffix) > 0 {
svcNameSuffix = "-" + svcNameSuffix
}

relPath := ruleConfig.RelPath
if relPath == "" {
relPath = "/"
}

ingressRules := []netv1.IngressRule{}

// prefer ServicePortName over ServicePortNumber
serviceBackendPort := netv1.ServiceBackendPort{}
if ruleConfig.ServicePortName != "" {
serviceBackendPort.Name = ruleConfig.ServicePortName
} else {
serviceBackendPort.Number = ruleConfig.ServicePortNumber
}

for _, fqdn := range fqdns {
rule := netv1.IngressRule{
Host: fqdn,
IngressRuleValue: netv1.IngressRuleValue{
HTTP: &netv1.HTTPIngressRuleValue{
Paths: []netv1.HTTPIngressPath{
{
Path: relPath,
PathType: ptr.To(netv1.PathType("Prefix")),
Backend: netv1.IngressBackend{
Service: &netv1.IngressServiceBackend{
Name: comp.GetName() + svcNameSuffix,
Port: serviceBackendPort,
},
},
},
},
},
},
}
ingressRules = append(ingressRules, rule)
}

return ingressRules
}

// Generate an ingress TLS secret name as "<baseName>-ingress-cert"
func generateTlsSecretName(baseName string) string {
return strings.Trim(baseName, "-") + "-ingress-cert"
}

// Generate an ingress name. additionalNames will be assembled as "comp.GetName()-<n1>-<n2>-<nX>-ingress"
func generateIngressName(comp InfoGetter, additionalNames ...string) string {
ingressName := comp.GetName()
if len(additionalNames) > 0 {
for _, n := range additionalNames {
n = strings.Trim(n, "-")
ingressName = ingressName + "-" + n
}
}
ingressName = ingressName + "-ingress"

return ingressName
}

// Generate up to 2 ingresses that bundle FQDNs depending on the following:
// FQDNs that are one subdomain ON defaultAppsDomain (e.g. sub1.apps.cluster.com) -> Empty TLS config (uses wildcard cert on OCP).
// FQDNs that do not statisfy the former -> TLS config using a Let's Encrypt certificate.
func GenerateBundledIngresses(comp InfoGetter, svc *runtime.ServiceRuntime, ingressConfig IngressConfig) ([]*netv1.Ingress, error) {
if len(ingressConfig.FQDNs) == 0 {
return nil, fmt.Errorf("no FQDNs")
}

for _, fqdn := range ingressConfig.FQDNs {
if fqdn == "" {
return nil, fmt.Errorf("an empty FQDN has been passed. Passed FQDNs are: %v", ingressConfig.FQDNs)
}
}

// bool: true -> Use wildcard, false -> Use LE, []string: List of FQDNs
ingressMap := map[bool][]string{}

ocpDefaultAppsDomain := svc.Config.Data["ocpDefaultAppsDomain"]
svc.Log.Info(fmt.Sprintf("ocpAppsDomain is: '%s'", ocpDefaultAppsDomain))

if ocpDefaultAppsDomain != "" {
for _, fqdn := range ingressConfig.FQDNs {
useWildcard := IsSingleSubdomainOfRefDomain(fqdn, ocpDefaultAppsDomain)
svc.Log.Info(fmt.Sprintf("FQDN %s will use wildcard cert: %v", fqdn, useWildcard))
ingressMap[useWildcard] = append(ingressMap[useWildcard], fqdn)
}
} else {
ingressMap[false] = ingressConfig.FQDNs
}

// Create ingresses that bundle FQDNs depending on their certificate requirements (LE/Wildcard)
var ingresses []*netv1.Ingress
fqdnsLetsEncrypt, fqdnsWildcard := ingressMap[false], ingressMap[true]

tlsName := generateTlsSecretName(ingressConfig.TlsCertBaseName)
if tlsName == "" {
return nil, fmt.Errorf("a TLS cert base name must be defined")
}

ingressMetadata := metav1.ObjectMeta{
Namespace: comp.GetInstanceNamespace(),
Annotations: getIngressAnnotations(svc, ingressConfig.AdditionalAnnotations),
}

// Ingress using Let's Encrypt
if len(fqdnsLetsEncrypt) > 0 {
ingressMetadata.Name = generateIngressName(comp, "letsencrypt")
ingresses = append(ingresses, &netv1.Ingress{
ObjectMeta: ingressMetadata,
Spec: netv1.IngressSpec{
Rules: createIngressRule(comp, fqdnsLetsEncrypt, ingressConfig.ServiceConfig),
TLS: []netv1.IngressTLS{
{
Hosts: fqdnsLetsEncrypt,
SecretName: tlsName,
},
},
},
})
}

// Ingress using apps domain wildcard
if len(fqdnsWildcard) > 0 {
ingressMetadata.Name = generateIngressName(comp, "wildcard")
ingresses = append(ingresses, &netv1.Ingress{
ObjectMeta: ingressMetadata,
Spec: netv1.IngressSpec{
Rules: createIngressRule(comp, fqdnsWildcard, ingressConfig.ServiceConfig),
TLS: []netv1.IngressTLS{{}},
},
})
}

return ingresses, nil
}

// Generate an Ingress containing a single FQDN using a TLS config as such:
// FQDN is one subdomain ON defaultAppsDomain (e.g. sub1.apps.cluster.com) -> Empty TLS config (uses wildcard cert on OCP).
// FQDN does not statisfy the former -> TLS config using a Let's Encrypt certificate.
func GenerateIngress(comp InfoGetter, svc *runtime.ServiceRuntime, ingressConfig IngressConfig) (*netv1.Ingress, error) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

I would definitely add some unit tests for this function, also for others as well

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added such in 4b1aa1e

if len(ingressConfig.FQDNs) == 0 || ingressConfig.FQDNs[0] == "" {
return nil, fmt.Errorf("no FQDN passed")
}

fqdn := ingressConfig.FQDNs[0]
if len(ingressConfig.FQDNs) > 1 {
svc.AddResult(
runtime.NewWarningResult(
fmt.Sprintf("More than 1 FQDN has been passed to a singleton ingress object, using first available: %s", fqdn),
),
)
}

annotations := getIngressAnnotations(svc, ingressConfig.AdditionalAnnotations)

tlsName := ingressConfig.TlsCertBaseName
if tlsName == "" {
return nil, fmt.Errorf("a TLS cert base name must be defined")
}
for _, an := range ingressConfig.AdditionalIngressNames {
tlsName = tlsName + "-" + strings.Trim(an, "-")
}

tlsConfig := netv1.IngressTLS{}
if !IsSingleSubdomainOfRefDomain(fqdn, svc.Config.Data["ocpDefaultAppsDomain"]) {
tlsConfig.Hosts = []string{fqdn}
tlsConfig.SecretName = generateTlsSecretName(tlsName)
}

ingress := &netv1.Ingress{
ObjectMeta: metav1.ObjectMeta{
Name: generateIngressName(comp, ingressConfig.AdditionalIngressNames...),
Namespace: comp.GetInstanceNamespace(),
Annotations: annotations,
},
Spec: netv1.IngressSpec{
Rules: createIngressRule(comp, []string{fqdn}, ingressConfig.ServiceConfig),
TLS: []netv1.IngressTLS{tlsConfig},
},
}

return ingress, nil
}

// Apply generated ingresses using svc.SetDesiredKubeObject()
func CreateIngresses(comp InfoGetter, svc *runtime.ServiceRuntime, ingresses []*netv1.Ingress, opts ...runtime.KubeObjectOption) error {
for _, ingress := range ingresses {
err := svc.SetDesiredKubeObject(ingress, ingress.Name, opts...)
if err != nil {
return err
}
}

return nil
}
4 changes: 3 additions & 1 deletion pkg/comp-functions/functions/common/util.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package common

import "fmt"
import (
"fmt"
)

// SetNestedObjectValue is necessary as unstructured can't handle anything except basic values and maps.
// this is a recursive function, it will traverse the map until it reaches the last element of the path.
Expand Down
75 changes: 16 additions & 59 deletions pkg/comp-functions/functions/vshnforgejo/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (
"strings"

xfnproto "github.com/crossplane/function-sdk-go/proto/v1beta1"
"github.com/ghodss/yaml"
vshnv1 "github.com/vshn/appcat/v4/apis/vshn/v1"
"github.com/vshn/appcat/v4/pkg/comp-functions/functions/common"
"github.com/vshn/appcat/v4/pkg/comp-functions/runtime"
Expand Down Expand Up @@ -50,6 +49,8 @@ func DeployForgejo(ctx context.Context, comp *vshnv1.VSHNForgejo, svc *runtime.S
return runtime.NewWarningResult(fmt.Sprintf("cannot add forgejo release: %s", err))
}

svc.Log.Info("Have added forgejo release!")

return nil
}

Expand Down Expand Up @@ -143,19 +144,6 @@ func addForgejo(ctx context.Context, svc *runtime.ServiceRuntime, comp *vshnv1.V
"enabled": true,
},
},
"ingress": map[string]any{
"annotations": map[string]string{
"cert-manager.io/cluster-issuer": "letsencrypt-staging",
},
"enabled": true,
"hosts": []map[string]any{},
"tls": []map[string]any{
{
"hosts": []string{},
"secretName": "forgejo-tls",
},
},
},
"persistence": map[string]any{
"enabled": true,
},
Expand Down Expand Up @@ -183,38 +171,22 @@ func addForgejo(ctx context.Context, svc *runtime.ServiceRuntime, comp *vshnv1.V
"type": "Recreate",
},
}
/*
hosts:
- host: forgejo213.apps.whatever.cloud
paths:
- path: /
pathType: Prefix
*/
nonTLShosts := []map[string]any{}

for _, host := range comp.Spec.Parameters.Service.FQDN {
// building an array of maps with the host and the paths
nonTLShosts = append(nonTLShosts, map[string]any{
"host": host,
"paths": []map[string]any{
{
"path": "/",
"pathType": "Prefix",
},
},
})

err := common.SetNestedObjectValue(values, []string{"ingress", "tls"}, []map[string]any{
{
"hosts": comp.Spec.Parameters.Service.FQDN,
"secretName": "forgejo-tls",
}})
if err != nil {
return err
}
svc.Log.Info("Adding ingress")
ingressConfig := common.IngressConfig{
FQDNs: comp.Spec.Parameters.Service.FQDN,
ServiceConfig: common.IngressRuleConfig{
ServiceNameSuffix: "http",
ServicePortNumber: 3000,
},
TlsCertBaseName: "forgejo",
}
ingresses, err := common.GenerateBundledIngresses(comp, svc, ingressConfig)
if err != nil {
return err
}

err := common.SetNestedObjectValue(values, []string{"ingress", "hosts"}, nonTLShosts)
err = common.CreateIngresses(comp, svc, ingresses)
if err != nil {
return err
}
Expand All @@ -224,23 +196,8 @@ func addForgejo(ctx context.Context, svc *runtime.ServiceRuntime, comp *vshnv1.V
values["podSecurityContext"] = securityContext["podSecurityContext"]
}

if svc.Config.Data["ingress_annotations"] != "" {
annotations := map[string]string{}

err := yaml.Unmarshal([]byte(svc.Config.Data["ingress_annotations"]), &annotations)
if err != nil {
// do nothing and use default annotation which sets issuer to staging
svc.Log.Error(fmt.Errorf("cannot unmarshal ingress annotations"), "error", err)
}

err = common.SetNestedObjectValue(values, []string{"ingress", "annotations"}, annotations)
if err != nil {
return err
}
}

if comp.Spec.Parameters.Service.AdminEmail != "" {
err = common.SetNestedObjectValue(values, []string{"gitea", "config", "admin", "ADMIN_EMAIL"}, comp.Spec.Parameters.Service.AdminEmail)
err := common.SetNestedObjectValue(values, []string{"gitea", "config", "admin", "ADMIN_EMAIL"}, comp.Spec.Parameters.Service.AdminEmail)
if err != nil {
return err
}
Expand Down
Loading
Loading