diff --git a/class/defaults.yml b/class/defaults.yml index 8a62c85..cfb6bcc 100644 --- a/class/defaults.yml +++ b/class/defaults.yml @@ -10,3 +10,13 @@ parameters: - syn namespaces: {} + + labelSync: + ignoreNames: [] + ignorePrefixes: + - cilium + - kube + - openshift + - appuio + - syn + applyOnPrefix: {} diff --git a/class/namespaces.yml b/class/namespaces.yml index ccc68d4..4967f82 100644 --- a/class/namespaces.yml +++ b/class/namespaces.yml @@ -7,5 +7,6 @@ parameters: output_path: . - input_paths: - ${_base_directory}/component/main.jsonnet + - ${_base_directory}/component/espejote.jsonnet input_type: jsonnet output_path: ${_instance}/ diff --git a/component/espejote-templates/functions-v1.libsonnet b/component/espejote-templates/functions-v1.libsonnet new file mode 100644 index 0000000..c077ea1 --- /dev/null +++ b/component/espejote-templates/functions-v1.libsonnet @@ -0,0 +1,50 @@ +// Check if the namespace should be ignored +local ignoreNamespace(namespace, config) = + if std.member(config.ignoreNames, namespace.metadata.name) then + true + else if std.length(std.filter( + function(prefix) std.startsWith(namespace.metadata.name, prefix), + config.ignorePrefixes + )) > 0 then + true + else false; + +// Get the labels from a namespace name prefix by +// first finding the prefixes that match with the namespace name +// then return the labels defined for that prefix. +local labelsFromPrefix(namespace, config) = + local filteredPrefixes = std.filter( + function(prefix) std.startsWith(namespace.metadata.name, prefix), + std.objectFields(config.applyOnPrefix) + ); + // If multiple prefixes define the same key, the more specific prefix wins. + local sortedPrefixes = std.sort( + filteredPrefixes, + function(obj) std.length(obj) + ); + + std.foldl( + function(acc, prefix) acc + config.applyOnPrefix[prefix], + sortedPrefixes, + {} + ); + +// Reconcile the given namespace. +local reconcileNamespace(namespace, config) = + // Check if the namespace can be ignored + if ignoreNamespace(namespace, config) then [] + // Apply labels if the namespace name starts with defined prefixes + else if labelsFromPrefix(namespace, config) != {} then [ + namespace { + metadata+: { + labels+: labelsFromPrefix(namespace, config), + }, + }, + ] + else []; + +{ + ignoreNamespace: ignoreNamespace, + labelsFromPrefix: labelsFromPrefix, + reconcileNamespace: reconcileNamespace, +} diff --git a/component/espejote-templates/label-sync.jsonnet b/component/espejote-templates/label-sync.jsonnet new file mode 100644 index 0000000..756be24 --- /dev/null +++ b/component/espejote-templates/label-sync.jsonnet @@ -0,0 +1,25 @@ +local esp = import 'espejote.libsonnet'; +local context = esp.context(); + +// check if the object is getting deleted by checking if it has +// `metadata.deletionTimestamp`. +local inDelete(obj) = std.get(obj.metadata, 'deletionTimestamp', '') != ''; + +// Do the thing +if esp.triggerName() == 'namespace' then ( + // Handle single namespace update on namespace trigger + local nsTrigger = esp.triggerData(); + // nsTrigger.resource can be null if we're called when the namespace is getting + // deleted. If it's not null, we still don't want to do anything when the + // namespace is getting deleted. + if nsTrigger.resource != null && !inDelete(nsTrigger.resource) then + functions.reconcileNamespace(nsTrigger.resource, config) +) else ( + // Reconcile all namespaces for managedresource reconcile. + local namespaces = context.namespaces; + std.flattenArrays([ + functions.reconcileNamespace(ns, config) + for ns in namespaces + if !inDelete(ns) + ]) +) diff --git a/component/espejote.jsonnet b/component/espejote.jsonnet new file mode 100644 index 0000000..dbbbc55 --- /dev/null +++ b/component/espejote.jsonnet @@ -0,0 +1,177 @@ +local com = import 'lib/commodore.libjsonnet'; +local esp = import 'lib/espejote.libsonnet'; +local kap = import 'lib/kapitan.libjsonnet'; +local kube = import 'lib/kube.libjsonnet'; +local utils = import 'utils.libsonnet'; + +// The hiera parameters for the component +local inv = kap.inventory(); +local params = inv.parameters.namespaces; +local instanceName = inv.parameters._instance; + +local espNamespace = inv.parameters.espejote.namespace; +local mrName = '%s-label-sync' % instanceName; +local rbacName = 'managedresource-%s-label-sync' % instanceName; + +// RBAC for Espejote +local espejoteRBAC = [ + { + apiVersion: 'v1', + kind: 'ServiceAccount', + metadata: { + labels: { + 'app.kubernetes.io/component': 'rbac', + 'app.kubernetes.io/name': mrName, + }, + name: mrName, + namespace: espNamespace, + }, + }, + { + apiVersion: 'v1', + kind: 'ClusterRole', + metadata: { + labels: { + 'app.kubernetes.io/component': 'rbac', + 'app.kubernetes.io/name': rbacName, + }, + name: rbacName, + }, + rules: [ + { + apiGroups: [ '' ], + resources: [ 'namespaces' ], + verbs: [ 'get', 'list', 'watch', 'patch' ], + }, + { + apiGroups: [ 'espejote.io' ], + resources: [ 'jsonnetlibraries' ], + resourceNames: [ mrName ], + verbs: [ 'get', 'list', 'watch' ], + }, + ], + }, + { + apiVersion: 'rbac.authorization.k8s.io/v1', + kind: 'ClusterRoleBinding', + metadata: { + labels: { + 'app.kubernetes.io/component': 'rbac', + 'app.kubernetes.io/name': rbacName, + }, + name: rbacName, + }, + roleRef: { + apiGroup: 'rbac.authorization.k8s.io', + kind: 'ClusterRole', + name: rbacName, + }, + subjects: [ + { + kind: 'ServiceAccount', + name: mrName, + namespace: espNamespace, + }, + ], + }, +]; + +// Espejote resources +local jsonnetLibrary = esp.jsonnetLibrary(mrName, espNamespace) { + spec: { + data: { + 'functions_v1.libsonnet': importstr 'espejote-templates/functions-v1.libsonnet', + }, + }, +}; + +local managedResource = esp.managedResource(mrName, espNamespace) { + metadata+: { + annotations: { + 'syn.tools/description': ||| + Manages labels of namespaces based on namespace prefixes. + See https://hub.syn.tools/namespaces/index.html for details. + |||, + }, + }, + spec: { + context: [ + { + name: 'namespaces', + resource: { + apiVersion: 'v1', + kind: 'Namespace', + }, + }, + ], + triggers: [ + { + name: 'namespace', + watchContextResource: { + name: 'namespaces', + }, + }, + { + name: 'jslib', + watchResource: { + apiVersion: 'espejote.io/v1alpha1', + kind: 'JsonnetLibrary', + name: mrName, + namespace: espNamespace, + }, + }, + ], + serviceAccountRef: { + name: espejoteRBAC[0].metadata.name, + }, + template: ('local config = %s;\n' % std.manifestJson(params.labelSync)) + ("local functions = import 'lib/%s/functions_v1.libsonnet';\n" % mrName) + (importstr 'espejote-templates/label-sync.jsonnet'), + }, +}; + +// Construct tests +local testFunctions = import 'espejote-templates/functions-v1.libsonnet'; +local testData = [ + { + apiVersion: 'v1', + kind: 'Namespace', + metadata: { + name: 'test', + }, + }, + { + apiVersion: 'v1', + kind: 'Namespace', + metadata: { + name: 'vshn-postgres', + }, + }, + { + apiVersion: 'v1', + kind: 'Namespace', + metadata: { + name: 'vshn-postgres-test', + }, + }, +]; + +// Check if espejote is installed and resources are configured +local hasEspejote = std.member(inv.applications, 'espejote'); +local hasDynamicLabels = std.length(params.labelSync.applyOnPrefix) > 0; + +// Define outputs below +if hasDynamicLabels && hasEspejote then + { + '00_espejote_rbac': espejoteRBAC, + '00_espejote_jslib': jsonnetLibrary, + '00_espejote_mr': managedResource, + [if std.get(params, '_enableSecretRuleTests', false) then '99_secret_tests']: [ + testFunctions.reconcileNamespace(data, params.labelSync) + for data in testData + ], + } +else if hasDynamicLabels then + std.trace( + 'espejote must be installed', + {} + ) +else {} diff --git a/docs/modules/ROOT/pages/references/parameters.adoc b/docs/modules/ROOT/pages/references/parameters.adoc index d3062bc..bf9f530 100644 --- a/docs/modules/ROOT/pages/references/parameters.adoc +++ b/docs/modules/ROOT/pages/references/parameters.adoc @@ -21,6 +21,63 @@ default:: {} Contains a list of namespaces to create. +== `labelSync` + +Configures dynamic Roles and RoleBindings managed by Espejote. + +=== `labelSync.ignoredNames` + +[horizontal] +type:: list of strings +default:: empty list + +A list of namespace names where no labels will be applied. +Entries in the list can be removed by adding the entry prefixed with a `~`. + +=== `labelSync.ignoredPrefixes` + +[horizontal] +type:: list of strings +default:: ++ +[source,yaml] +---- +labelSync: + ignoredPrefixes: + - kube + - openshift + - appuio + - syn +---- + +A list of namespace name-prefixes where no labels will be applied. +Entries in the list can be removed by adding the entry prefixed with a `~`. + +=== `labelSync.applyOnPrefix` + +[horizontal] +type:: dict +default:: `{}` +example:: ++ +[source,yaml] +---- +labelSync: + applyOnPrefix: + 'vshn-postgres': <1> + 'set.rbac.syn.tools/allow-team1': '' <2> +---- +<1> Matches all namespaces that start with `vshn-postgres`. +<2> Applies the label `set.rbac.syn.tools/allow-team1=''` to the matching namespace. + +Custom set of namespace prefixes, where the listed labels will be applied. + +[IMPORTANT] +==== +Using this with too broad settings can have unintended sideeffects! +==== + + == Example [source,yaml] diff --git a/tests/golden/team1/team1/team1/00_espejote_jslib.yaml b/tests/golden/team1/team1/team1/00_espejote_jslib.yaml new file mode 100644 index 0000000..1b6cc94 --- /dev/null +++ b/tests/golden/team1/team1/team1/00_espejote_jslib.yaml @@ -0,0 +1,60 @@ +apiVersion: espejote.io/v1alpha1 +kind: JsonnetLibrary +metadata: + labels: + app.kubernetes.io/name: team1-label-sync + name: team1-label-sync + namespace: syn-espejote +spec: + data: + functions_v1.libsonnet: | + // Check if the namespace should be ignored + local ignoreNamespace(namespace, config) = + if std.member(config.ignoreNames, namespace.metadata.name) then + true + else if std.length(std.filter( + function(prefix) std.startsWith(namespace.metadata.name, prefix), + config.ignorePrefixes + )) > 0 then + true + else false; + + // Get the labels from a namespace name prefix by + // first finding the prefixes that match with the namespace name + // then return the labels defined for that prefix. + local labelsFromPrefix(namespace, config) = + local filteredPrefixes = std.filter( + function(prefix) std.startsWith(namespace.metadata.name, prefix), + std.objectFields(config.applyOnPrefix) + ); + // If multiple prefixes define the same key, the more specific prefix wins. + local sortedPrefixes = std.sort( + filteredPrefixes, + function(obj) std.length(obj) + ); + + std.foldl( + function(acc, prefix) acc + config.applyOnPrefix[prefix], + sortedPrefixes, + {} + ); + + // Reconcile the given namespace. + local reconcileNamespace(namespace, config) = + // Check if the namespace can be ignored + if ignoreNamespace(namespace, config) then [] + // Apply labels if the namespace name starts with defined prefixes + else if labelsFromPrefix(namespace, config) != {} then [ + namespace { + metadata+: { + labels+: labelsFromPrefix(namespace, config), + }, + }, + ] + else []; + + { + ignoreNamespace: ignoreNamespace, + labelsFromPrefix: labelsFromPrefix, + reconcileNamespace: reconcileNamespace, + } diff --git a/tests/golden/team1/team1/team1/00_espejote_mr.yaml b/tests/golden/team1/team1/team1/00_espejote_mr.yaml new file mode 100644 index 0000000..8dd0c34 --- /dev/null +++ b/tests/golden/team1/team1/team1/00_espejote_mr.yaml @@ -0,0 +1,80 @@ +apiVersion: espejote.io/v1alpha1 +kind: ManagedResource +metadata: + annotations: + syn.tools/description: | + Manages labels of namespaces based on namespace prefixes. + See https://hub.syn.tools/namespaces/index.html for details. + labels: + app.kubernetes.io/name: team1-label-sync + name: team1-label-sync + namespace: syn-espejote +spec: + context: + - name: namespaces + resource: + apiVersion: v1 + kind: Namespace + serviceAccountRef: + name: team1-label-sync + template: | + local config = { + "applyOnPrefix": { + "vshn-keycloak": { + "set.rbac.syn.tools/allow-team1": "" + }, + "vshn-postgres": { + "set.rbac.syn.tools/allow-team2": "foo" + }, + "vshn-postgres-test": { + "set.rbac.syn.tools/allow-team2": "bar", + "set.rbac.syn.tools/allow-team3": "" + } + }, + "ignoreNames": [ + + ], + "ignorePrefixes": [ + "cilium", + "kube", + "openshift", + "appuio", + "syn" + ] + }; + local functions = import 'lib/team1-label-sync/functions_v1.libsonnet'; + local esp = import 'espejote.libsonnet'; + local context = esp.context(); + + // check if the object is getting deleted by checking if it has + // `metadata.deletionTimestamp`. + local inDelete(obj) = std.get(obj.metadata, 'deletionTimestamp', '') != ''; + + // Do the thing + if esp.triggerName() == 'namespace' then ( + // Handle single namespace update on namespace trigger + local nsTrigger = esp.triggerData(); + // nsTrigger.resource can be null if we're called when the namespace is getting + // deleted. If it's not null, we still don't want to do anything when the + // namespace is getting deleted. + if nsTrigger.resource != null && !inDelete(nsTrigger.resource) then + functions.reconcileNamespace(nsTrigger.resource, config) + ) else ( + // Reconcile all namespaces for managedresource reconcile. + local namespaces = context.namespaces; + std.flattenArrays([ + functions.reconcileNamespace(ns, config) + for ns in namespaces + if !inDelete(ns) + ]) + ) + triggers: + - name: namespace + watchContextResource: + name: namespaces + - name: jslib + watchResource: + apiVersion: espejote.io/v1alpha1 + kind: JsonnetLibrary + name: team1-label-sync + namespace: syn-espejote diff --git a/tests/golden/team1/team1/team1/00_espejote_rbac.yaml b/tests/golden/team1/team1/team1/00_espejote_rbac.yaml new file mode 100644 index 0000000..887b5ce --- /dev/null +++ b/tests/golden/team1/team1/team1/00_espejote_rbac.yaml @@ -0,0 +1,52 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + labels: + app.kubernetes.io/component: rbac + app.kubernetes.io/name: team1-label-sync + name: team1-label-sync + namespace: syn-espejote +--- +apiVersion: v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/component: rbac + app.kubernetes.io/name: managedresource-team1-label-sync + name: managedresource-team1-label-sync +rules: + - apiGroups: + - '' + resources: + - namespaces + verbs: + - get + - list + - watch + - patch + - apiGroups: + - espejote.io + resourceNames: + - team1-label-sync + resources: + - jsonnetlibraries + verbs: + - get + - list + - watch +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + labels: + app.kubernetes.io/component: rbac + app.kubernetes.io/name: managedresource-team1-label-sync + name: managedresource-team1-label-sync +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: managedresource-team1-label-sync +subjects: + - kind: ServiceAccount + name: team1-label-sync + namespace: syn-espejote diff --git a/tests/golden/team1/team1/team1/99_secret_tests.yaml b/tests/golden/team1/team1/team1/99_secret_tests.yaml new file mode 100644 index 0000000..e1cff4f --- /dev/null +++ b/tests/golden/team1/team1/team1/99_secret_tests.yaml @@ -0,0 +1,16 @@ +[] +--- +- apiVersion: v1 + kind: Namespace + metadata: + labels: + set.rbac.syn.tools/allow-team2: foo + name: vshn-postgres +--- +- apiVersion: v1 + kind: Namespace + metadata: + labels: + set.rbac.syn.tools/allow-team2: bar + set.rbac.syn.tools/allow-team3: '' + name: vshn-postgres-test diff --git a/tests/team1.yml b/tests/team1.yml index c86a084..f537333 100644 --- a/tests/team1.yml +++ b/tests/team1.yml @@ -1,9 +1,33 @@ +applications: + - espejote + parameters: + kapitan: + dependencies: + - type: https + source: https://raw.githubusercontent.com/projectsyn/component-espejote/master/lib/espejote.libsonnet + output_path: vendor/lib/espejote.libsonnet + + espejote: + namespace: syn-espejote + namespaces: ignoreList: - cilium + team1: namespaces: my-namespace: annotations: team: team1 + labelSync: + applyOnPrefix: + 'vshn-keycloak': + 'set.rbac.syn.tools/allow-team1': '' + 'vshn-postgres': + 'set.rbac.syn.tools/allow-team2': 'foo' + 'vshn-postgres-test': + 'set.rbac.syn.tools/allow-team2': 'bar' + 'set.rbac.syn.tools/allow-team3': '' + + _enableSecretRuleTests: true