Skip to content

Commit 8bf8435

Browse files
authored
add clusteraccess library (#52)
* add clusteraccess library * adapt to changed resource mutators * move Labels type into own package
1 parent 3470c2b commit 8bf8435

File tree

9 files changed

+1071
-0
lines changed

9 files changed

+1071
-0
lines changed

docs/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@
33

44
## Libraries
55

6+
- [Generating Kubeconfigs for k8s Clusters](libs/clusteraccess.md)
67
- [Connecting to Kubernetes Clusters](libs/clusters.md)
78
- [Collections](libs/collections.md)
89
- [Controller Utility Functions](libs/controller.md)
910
- [Custom Resource Definitions](libs/crds.md)
1011
- [Error Handling](libs/errors.md)
1112
- [Logging](libs/logging.md)
13+
- [Key-Value Pairs](libs/pairs.md)
1214
- [Readiness Checks](libs/readiness.md)
1315
- [Kubernetes Resource Management](libs/resource.md)
1416
- [Kubernetes Resource Status Updating](libs/status.md)

docs/libs/clusteraccess.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Generating Kubeconfigs for k8s Clusters
2+
3+
The `pkg/clusteraccess` package contains useful helper functions to create a kubeconfig for a k8s cluster. This includes functions to create ServiceAccounts as well as (Cluster)Roles and (Cluster)RoleBindings, but also generating a ServiceAccount token and building a kubeconfig from this token.

docs/libs/pairs.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Key-Value Pairs
2+
3+
The `pkg/pairs` library contains mainly the `Pairs` type, which is a generic type representing a single key-value pair.
4+
The `MapToPairs` and `PairsToMap` helper functions can convert between `map[K]V` and `[]Pair[K, V]`.
5+
This is useful for example if key-value pairs are meant to be passed into a function as variadic arguments:
6+
```go
7+
func myFunc(labels ...Pair[string]string) {
8+
labelMap := PairsToMap(labels)
9+
<...>
10+
}
11+
12+
func main() {
13+
myFunc(pairs.New("foo", "bar"), pairs.New("bar", "baz"))
14+
}
15+
```
16+
17+
The `Sort` and `SortStable` functions as well as the `Compare` method of `Pair` can be used to compare and sort pairs by their keys. Note that these functions will panic if the key cannot be converted into an `int64`, `float64`, `string`, or does implement the package's `Comparable` interface.
18+
If the interface is implemented, its `Compare` implementation takes precedence over the conversion into one of the mentioned base types.

pkg/clusteraccess/access.go

Lines changed: 343 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,343 @@
1+
package clusteraccess
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"time"
7+
8+
authenticationv1 "k8s.io/api/authentication/v1"
9+
corev1 "k8s.io/api/core/v1"
10+
rbacv1 "k8s.io/api/rbac/v1"
11+
apierrors "k8s.io/apimachinery/pkg/api/errors"
12+
"k8s.io/client-go/rest"
13+
"k8s.io/client-go/tools/clientcmd"
14+
clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
15+
"k8s.io/utils/ptr"
16+
"sigs.k8s.io/controller-runtime/pkg/client"
17+
18+
"github.com/openmcp-project/controller-utils/pkg/pairs"
19+
"github.com/openmcp-project/controller-utils/pkg/resources"
20+
)
21+
22+
type Label = pairs.Pair[string, string]
23+
24+
// GetTokenBasedAccess is a convenience function that wraps the flow of ensuring namespace, serviceaccount, (cluster)role(binding), and creating the token.
25+
// It returns a kubeconfig, the token with expiration timestamp, and an error if any of the steps fail.
26+
// The name will be used for all resources except the namespace (serviceaccount, (cluster)role, (cluster)rolebinding), with anything role-related additionally being prefixed with rolePrefix.
27+
// The namespace holds the serviceaccount and, if namespaceScoped is true, the role and rolebinding.
28+
// If namespaceScoped is false, clusterrole and clusterrolebinding are used.
29+
func GetTokenBasedAccess(ctx context.Context, c client.Client, restCfg *rest.Config, name, namespace string, namespaceScoped bool, rolePrefix string, rules []rbacv1.PolicyRule, expectedLabels ...Label) ([]byte, *ServiceAccountToken, error) {
30+
if namespace == "" {
31+
return nil, nil, fmt.Errorf("no namespace provided for ServiceAccount")
32+
}
33+
34+
_, err := EnsureNamespace(ctx, c, namespace, expectedLabels...)
35+
if err != nil {
36+
return nil, nil, err
37+
}
38+
39+
sa, err := EnsureServiceAccount(ctx, c, name, namespace, expectedLabels...)
40+
if err != nil {
41+
return nil, nil, err
42+
}
43+
44+
subjects := []rbacv1.Subject{{Kind: rbacv1.ServiceAccountKind, Name: name, Namespace: namespace}}
45+
if namespaceScoped {
46+
_, _, err = EnsureRoleAndBinding(ctx, c, rolePrefix+name, namespace, subjects, rules, expectedLabels...)
47+
if err != nil {
48+
return nil, nil, err
49+
}
50+
} else {
51+
_, _, err = EnsureClusterRoleAndBinding(ctx, c, rolePrefix+name, subjects, rules, expectedLabels...)
52+
if err != nil {
53+
return nil, nil, err
54+
}
55+
}
56+
57+
sat, err := CreateTokenForServiceAccount(ctx, c, sa, nil)
58+
if err != nil {
59+
return nil, nil, err
60+
}
61+
62+
kcfg, err := CreateTokenKubeconfig(name, restCfg.Host, restCfg.CAData, sat.Token)
63+
if err != nil {
64+
return nil, nil, err
65+
}
66+
67+
return kcfg, sat, nil
68+
}
69+
70+
// EnsureNamespace ensures that the specified Namespace exists.
71+
// If it doesn't exist, it is created with the expected labels.
72+
// If it exists, but does not have the expected labels, a ResourceNotManagedError is returned.
73+
// The namespace is returned.
74+
func EnsureNamespace(ctx context.Context, c client.Client, nsName string, expectedLabels ...Label) (*corev1.Namespace, error) {
75+
ns := &corev1.Namespace{}
76+
ns.SetName(nsName)
77+
found := true
78+
if err := c.Get(ctx, client.ObjectKeyFromObject(ns), ns); err != nil {
79+
if !apierrors.IsNotFound(err) {
80+
return nil, fmt.Errorf("error getting Namespace '%s': %w", ns.Name, err)
81+
}
82+
found = false
83+
}
84+
if found {
85+
if err := FailIfNotManaged(ns, expectedLabels...); err != nil {
86+
return nil, err
87+
}
88+
// a namespace does not have any spec, so we don't have to do anything, if it was found
89+
return ns, nil
90+
}
91+
ns.SetLabels(pairs.PairsToMap(expectedLabels))
92+
if err := c.Create(ctx, ns); err != nil {
93+
return nil, fmt.Errorf("error creating Namespace '%s': %w", ns.Name, err)
94+
}
95+
96+
return ns, nil
97+
}
98+
99+
// EnsureServiceAccount ensures that the specified ServiceAccount exists.
100+
// If it doesn't exist, it is created with the expected labels (the namespace has to exist).
101+
// If it exists, but does not have the expected labels, a ResourceNotManagedError is returned.
102+
// The ServiceAccount is returned.
103+
func EnsureServiceAccount(ctx context.Context, c client.Client, saName, saNamespace string, expectedLabels ...Label) (*corev1.ServiceAccount, error) {
104+
sa := &corev1.ServiceAccount{}
105+
sa.SetName(saName)
106+
sa.SetNamespace(saNamespace)
107+
found := true
108+
if err := c.Get(ctx, client.ObjectKeyFromObject(sa), sa); err != nil {
109+
if !apierrors.IsNotFound(err) {
110+
return nil, fmt.Errorf("error getting ServiceAccount '%s/%s': %w", sa.Namespace, sa.Name, err)
111+
}
112+
found = false
113+
}
114+
if found {
115+
if err := FailIfNotManaged(sa, expectedLabels...); err != nil {
116+
return nil, err
117+
}
118+
// a serviceaccount does not have any relevant spec, so we don't have to do anything, if it was found
119+
return sa, nil
120+
}
121+
sa.SetLabels(pairs.PairsToMap(expectedLabels))
122+
if err := c.Create(ctx, sa); err != nil {
123+
return nil, fmt.Errorf("error creating ServiceAccount '%s': %w", sa.Name, err)
124+
}
125+
126+
return sa, nil
127+
}
128+
129+
// EnsureClusterRoleAndBinding combines EnsureClusterRole and EnsureClusterRoleBinding.
130+
// The name is used for both the ClusterRole and ClusterRoleBinding.
131+
func EnsureClusterRoleAndBinding(ctx context.Context, c client.Client, name string, subjects []rbacv1.Subject, rules []rbacv1.PolicyRule, expectedLabels ...Label) (*rbacv1.ClusterRoleBinding, *rbacv1.ClusterRole, error) {
132+
cr, err := EnsureClusterRole(ctx, c, name, rules, expectedLabels...)
133+
if err != nil {
134+
return nil, nil, err
135+
}
136+
crb, err := EnsureClusterRoleBinding(ctx, c, name, cr.Name, subjects, expectedLabels...)
137+
if err != nil {
138+
return nil, cr, err
139+
}
140+
return crb, cr, nil
141+
}
142+
143+
// EnsureClusterRole ensures that the specified ClusterRole exists with the specified rules.
144+
// If it doesn't exist, it is created with the expected labels.
145+
// If it exists, but does not have the expected labels, a ResourceNotManagedError is returned.
146+
// The ClusterRole is returned.
147+
func EnsureClusterRole(ctx context.Context, c client.Client, name string, rules []rbacv1.PolicyRule, expectedLabels ...Label) (*rbacv1.ClusterRole, error) {
148+
crm := resources.NewClusterRoleMutator(name, rules)
149+
crm.MetadataMutator().WithLabels(pairs.PairsToMap(expectedLabels))
150+
cr := crm.Empty()
151+
found := true
152+
if err := c.Get(ctx, client.ObjectKeyFromObject(cr), cr); err != nil {
153+
if !apierrors.IsNotFound(err) {
154+
return nil, fmt.Errorf("error getting ClusterRole '%s': %w", cr.Name, err)
155+
}
156+
found = false
157+
}
158+
if found {
159+
if err := FailIfNotManaged(cr, expectedLabels...); err != nil {
160+
return nil, err
161+
}
162+
}
163+
if err := resources.CreateOrUpdateResource(ctx, c, crm); err != nil {
164+
return nil, fmt.Errorf("error creating/updating ClusterRole '%s': %w", cr.Name, err)
165+
}
166+
return cr, nil
167+
}
168+
169+
// EnsureClusterRoleBinding ensures that the specified ClusterRoleBinding exists with the specified subjects.
170+
// If it doesn't exist, it is created with the expected labels.
171+
// If it exists, but does not have the expected labels, a ResourceNotManagedError is returned.
172+
// The ClusterRoleBinding is returned.
173+
func EnsureClusterRoleBinding(ctx context.Context, c client.Client, name, clusterRoleName string, subjects []rbacv1.Subject, expectedLabels ...Label) (*rbacv1.ClusterRoleBinding, error) {
174+
crbm := resources.NewClusterRoleBindingMutator(name, subjects, resources.NewClusterRoleRef(clusterRoleName))
175+
crbm.MetadataMutator().WithLabels(pairs.PairsToMap(expectedLabels))
176+
crb := crbm.Empty()
177+
found := true
178+
if err := c.Get(ctx, client.ObjectKeyFromObject(crb), crb); err != nil {
179+
if !apierrors.IsNotFound(err) {
180+
return nil, fmt.Errorf("error getting ClusterRoleBinding '%s': %w", crb.Name, err)
181+
}
182+
found = false
183+
}
184+
if found {
185+
if err := FailIfNotManaged(crb, expectedLabels...); err != nil {
186+
return nil, err
187+
}
188+
}
189+
if err := resources.CreateOrUpdateResource(ctx, c, crbm); err != nil {
190+
return nil, fmt.Errorf("error creating/updating ClusterRole '%s': %w", crb.Name, err)
191+
}
192+
return crb, nil
193+
}
194+
195+
// EnsureRoleAndBinding combines EnsureRole and EnsureRoleBinding.
196+
// The name is used for both the Role and RoleBinding.
197+
func EnsureRoleAndBinding(ctx context.Context, c client.Client, name, namespace string, subjects []rbacv1.Subject, rules []rbacv1.PolicyRule, expectedLabels ...Label) (*rbacv1.RoleBinding, *rbacv1.Role, error) {
198+
r, err := EnsureRole(ctx, c, name, namespace, rules, expectedLabels...)
199+
if err != nil {
200+
return nil, nil, err
201+
}
202+
rb, err := EnsureRoleBinding(ctx, c, name, namespace, r.Name, subjects, expectedLabels...)
203+
if err != nil {
204+
return nil, r, err
205+
}
206+
return rb, r, nil
207+
}
208+
209+
// EnsureRole ensures that the specified Role exists with the specified rules.
210+
// If it doesn't exist, it is created with the expected labels.
211+
// If it exists, but does not have the expected labels, a ResourceNotManagedError is returned.
212+
// The Role is returned.
213+
func EnsureRole(ctx context.Context, c client.Client, name, namespace string, rules []rbacv1.PolicyRule, expectedLabels ...Label) (*rbacv1.Role, error) {
214+
rm := resources.NewRoleMutator(name, namespace, rules)
215+
rm.MetadataMutator().WithLabels(pairs.PairsToMap(expectedLabels))
216+
r := rm.Empty()
217+
found := true
218+
if err := c.Get(ctx, client.ObjectKeyFromObject(r), r); err != nil {
219+
if !apierrors.IsNotFound(err) {
220+
return nil, fmt.Errorf("error getting Role '%s/%s': %w", r.Namespace, r.Name, err)
221+
}
222+
found = false
223+
}
224+
if found {
225+
if err := FailIfNotManaged(r, expectedLabels...); err != nil {
226+
return nil, err
227+
}
228+
}
229+
if err := resources.CreateOrUpdateResource(ctx, c, rm); err != nil {
230+
return nil, fmt.Errorf("error creating/updating Role '%s/%s': %w", r.Namespace, r.Name, err)
231+
}
232+
return r, nil
233+
}
234+
235+
// EnsureRoleBinding ensures that the specified RoleBinding exists with the specified subjects.
236+
// If it doesn't exist, it is created with the expected labels.
237+
// If it exists, but does not have the expected labels, a ResourceNotManagedError is returned.
238+
// The RoleBinding is returned.
239+
func EnsureRoleBinding(ctx context.Context, c client.Client, name, namespace, roleName string, subjects []rbacv1.Subject, expectedLabels ...Label) (*rbacv1.RoleBinding, error) {
240+
rbm := resources.NewRoleBindingMutator(name, namespace, subjects, resources.NewRoleRef(roleName))
241+
rbm.MetadataMutator().WithLabels(pairs.PairsToMap(expectedLabels))
242+
rb := rbm.Empty()
243+
found := true
244+
if err := c.Get(ctx, client.ObjectKeyFromObject(rb), rb); err != nil {
245+
if !apierrors.IsNotFound(err) {
246+
return nil, fmt.Errorf("error getting RoleBinding '%s/%s': %w", rb.Namespace, rb.Name, err)
247+
}
248+
found = false
249+
}
250+
if found {
251+
if err := FailIfNotManaged(rb, expectedLabels...); err != nil {
252+
return nil, err
253+
}
254+
}
255+
if err := resources.CreateOrUpdateResource(ctx, c, rbm); err != nil {
256+
return nil, fmt.Errorf("error creating/updating RoleBinding '%s/%s': %w", rb.Namespace, rb.Name, err)
257+
}
258+
return rb, nil
259+
}
260+
261+
// CreateTokenForServiceAccount generates a token for the given ServiceAccount.
262+
func CreateTokenForServiceAccount(ctx context.Context, c client.Client, sa *corev1.ServiceAccount, desiredDuration *time.Duration) (*ServiceAccountToken, error) {
263+
tr := &authenticationv1.TokenRequest{}
264+
if desiredDuration != nil {
265+
tr.Spec.ExpirationSeconds = ptr.To((int64)(desiredDuration.Seconds()))
266+
}
267+
268+
sat := &ServiceAccountToken{
269+
CreationTimestamp: time.Now(),
270+
}
271+
if err := c.SubResource("token").Create(ctx, sa, tr); err != nil {
272+
return nil, fmt.Errorf("error creating token for ServiceAccount '%s/%s': %w", sa.Namespace, sa.Name, err)
273+
}
274+
sat.Token = tr.Status.Token
275+
sat.ExpirationTimestamp = tr.Status.ExpirationTimestamp.Time
276+
277+
return sat, nil
278+
}
279+
280+
// ServiceAccountToken is a helper struct that bundles a ServiceAccount token together with its creation and expiration timestamps.
281+
type ServiceAccountToken struct {
282+
Token string
283+
CreationTimestamp time.Time
284+
ExpirationTimestamp time.Time
285+
}
286+
287+
// CreateTokenKubeconfig generates a kubeconfig based on the given values.
288+
// The 'user' arg is used as key for the auth configuration and can be chosen freely.
289+
func CreateTokenKubeconfig(user, host string, caData []byte, token string) ([]byte, error) {
290+
id := "cluster"
291+
kcfg := clientcmdapi.Config{
292+
APIVersion: "v1",
293+
Kind: "Config",
294+
Clusters: map[string]*clientcmdapi.Cluster{
295+
id: {
296+
Server: host,
297+
CertificateAuthorityData: caData,
298+
},
299+
},
300+
Contexts: map[string]*clientcmdapi.Context{
301+
id: {
302+
Cluster: id,
303+
AuthInfo: user,
304+
},
305+
},
306+
CurrentContext: id,
307+
AuthInfos: map[string]*clientcmdapi.AuthInfo{
308+
user: {
309+
Token: token,
310+
},
311+
},
312+
}
313+
314+
kcfgBytes, err := clientcmd.Write(kcfg)
315+
if err != nil {
316+
return nil, fmt.Errorf("error converting converting generated kubeconfig into yaml: %w", err)
317+
}
318+
return kcfgBytes, nil
319+
}
320+
321+
// ComputeTokenRenewalTime computes the time for the renewal of a token, given its creation and expiration time.
322+
// Returns the zero time if either of the given times is zero.
323+
// The returned time is when 80% of the validity duration is reached.
324+
// If another percentage is desired, use ComputeTokenRenewalTimeWithRatio instead.
325+
func ComputeTokenRenewalTime(creationTime, expirationTime time.Time) time.Time {
326+
return ComputeTokenRenewalTimeWithRatio(creationTime, expirationTime, 0.8)
327+
}
328+
329+
// ComputeTokenRenewalTime computes the time for the renewal of a token, given its creation and expiration time.
330+
// Returns the zero time if either of the given times is zero.
331+
// Ratio must be between 0 and 1. The returned time is when this percentage of the validity duration is reached.
332+
func ComputeTokenRenewalTimeWithRatio(creationTime, expirationTime time.Time, ratio float64) time.Time {
333+
if creationTime.IsZero() || expirationTime.IsZero() {
334+
return time.Time{}
335+
}
336+
// validity is how long the token was valid in the first place
337+
validity := expirationTime.Sub(creationTime)
338+
// renewalAfter is 80% of the validity
339+
renewalAfter := time.Duration(float64(validity) * ratio)
340+
// renewalAt is the point in time at which the token should be renewed
341+
renewalAt := creationTime.Add(renewalAfter)
342+
return renewalAt
343+
}

0 commit comments

Comments
 (0)