Skip to content

Commit

Permalink
Merge pull request #11 from vshn/cloudscale-create-user
Browse files Browse the repository at this point in the history
Create Objects users on cloudscale.ch
  • Loading branch information
ccremer authored Jul 8, 2022
2 parents e2950b1 + 728fed8 commit 5fcf896
Show file tree
Hide file tree
Showing 26 changed files with 988 additions and 33 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ jobs:
${{ runner.os }}-go-
- name: Run tests
run: make test
run: make test-integration

- name: Upload code coverage report to Code Climate
uses: paambaati/[email protected]
Expand Down
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,33 @@
VSHN opinionated operator to deploy S3 resources on supported cloud providers.

https://vshn.github.io/appcat-service-s3/

## Local Development

### Requirements

* `docker`
* `go`
* `helm`
* `kubectl`
* `yq`
* `sed` (or `gsed` for Mac)

Some other requirements (e.g. `kind`) will be compiled on-the-fly and put in the local cache dir `.kind` as needed.

### Common make targets

* `make build` to build the binary and docker image
* `make generate` to (re)generate additional code artifacts
* `make test` run test suite
* `make local-install` to install the operator in local cluster
* `make install-samples` to run the provider in local cluster and apply a sample instance
* `make run-operator` to run the code in operator mode against your current kubecontext

See all targets with `make help`

### QuickStart Demonstration

1. Get an API token cloudscale.ch
1. `export CLOUDSCALE_API_TOKEN=<the-token>`
1. `make local-install install-samples`
8 changes: 8 additions & 0 deletions apis/cloudscale/v1/objectsuser_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,19 @@ import (

// ObjectsUserSpec defines the desired state of an ObjectsUser.
type ObjectsUserSpec struct {
// +kubebuilder:validation:Required
// +kubebuilder:validation:Pattern=`[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*`

// SecretRef contains the name of the Secret where the credentials of the ObjectsUser are stored.
// Must be a name that Kubernetes accepts as Secret name (lowercase RFC 1123 subdomain).
SecretRef string `json:"secretRef"`
}

// ObjectsUserStatus represents the observed state of a ObjectsUser.
type ObjectsUserStatus struct {
Conditions []metav1.Condition `json:"conditions,omitempty"`
// UserID is the unique ID as generated by cloudscale.ch.
UserID string `json:"userID,omitempty"`
}

// +kubebuilder:object:root=true
Expand Down
34 changes: 34 additions & 0 deletions apis/conditions/builder.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package conditions

import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

// ConditionBuilder builds Conditions using various properties.
type ConditionBuilder struct {
condition metav1.Condition
}

// Builder returns a new ConditionBuilder instance.
func Builder() *ConditionBuilder {
return &ConditionBuilder{}
}

// With initializes the condition with the given value.
// Returns itself for convenience.
func (b *ConditionBuilder) With(condition metav1.Condition) *ConditionBuilder {
b.condition = condition
return b
}

// WithMessage sets the condition message.
// Returns itself for convenience.
func (b *ConditionBuilder) WithMessage(message string) *ConditionBuilder {
b.condition.Message = message
return b
}

// Build returns the condition.
func (b *ConditionBuilder) Build() metav1.Condition {
return b.condition
}
47 changes: 47 additions & 0 deletions apis/conditions/conditions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package conditions

import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"

// Reasons that give more context to conditions
const (
ReasonAvailable = "Available"
ReasonProvisioningFailed = "ProvisioningFailed"
)

const (
// TypeReady indicates that a resource is ready for use.
TypeReady = "Ready"
// TypeFailed indicates that a resource has failed the provisioning.
TypeFailed = "Failed"
)

// Ready creates a condition with TypeReady, ReasonAvailable and empty message.
func Ready() metav1.Condition {
return metav1.Condition{
Type: TypeReady,
Status: metav1.ConditionTrue,
LastTransitionTime: metav1.Now(),
Reason: ReasonAvailable,
}
}

// NotReady creates a condition with TypeReady, ReasonAvailable and empty message.
func NotReady() metav1.Condition {
return metav1.Condition{
Type: TypeReady,
Status: metav1.ConditionFalse,
LastTransitionTime: metav1.Now(),
Reason: ReasonAvailable,
}
}

// Failed creates a condition with TypeFailed, ReasonProvisioningFailed and the error message.
func Failed(err error) metav1.Condition {
return metav1.Condition{
Type: TypeFailed,
Status: metav1.ConditionTrue,
LastTransitionTime: metav1.Now(),
Reason: ReasonProvisioningFailed,
Message: err.Error(),
}
}
2 changes: 1 addition & 1 deletion charts/appcat-service-s3/templates/secret.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@ metadata:
name: {{ include "appcat-service-s3.fullname" . }}
labels:
{{- include "appcat-service-s3.labels" . | nindent 4 }}
data:
stringData:
CLOUDSCALE_API_TOKEN: {{ .Values.tokens.cloudscale }}
{{- end -}}
3 changes: 2 additions & 1 deletion docs/modules/ROOT/examples/cloudscale_objectsuser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ kind: ObjectsUser
metadata:
name: my-cloudscale-user
namespace: default
spec: {}
spec:
secretRef: my-cloudscale-user-credentials
10 changes: 6 additions & 4 deletions generate_sample.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,19 +30,21 @@ func main() {
}

func generateCloudscaleObjectsUserSample() {
spec := newPostgresqlStandaloneSample()
spec := newObjectsUserSample()
serialize(spec, true)
}

func newPostgresqlStandaloneSample() *cloudscalev1.ObjectsUser {
func newObjectsUserSample() *cloudscalev1.ObjectsUser {
return &cloudscalev1.ObjectsUser{
TypeMeta: metav1.TypeMeta{
APIVersion: cloudscalev1.ObjectsUserGroupVersionKind.GroupVersion().String(),
Kind: cloudscalev1.ObjectsUserKind,
},
ObjectMeta: metav1.ObjectMeta{Name: "my-cloudscale-user", Namespace: "default", Generation: 1},
Spec: cloudscalev1.ObjectsUserSpec{},
Status: cloudscalev1.ObjectsUserStatus{},
Spec: cloudscalev1.ObjectsUserSpec{
SecretRef: "my-cloudscale-user-credentials",
},
Status: cloudscalev1.ObjectsUserStatus{},
}
}

Expand Down
8 changes: 5 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,14 @@ go 1.18

require (
github.com/ccremer/go-command-pipeline v0.18.0
github.com/cloudscale-ch/cloudscale-go-sdk/v2 v2.0.1
github.com/go-logr/logr v1.2.3
github.com/go-logr/zapr v1.2.3
github.com/stretchr/testify v1.8.0
github.com/urfave/cli/v2 v2.10.3
go.uber.org/zap v1.21.0
golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8
k8s.io/api v0.24.2
k8s.io/apimachinery v0.24.2
k8s.io/client-go v0.24.2
sigs.k8s.io/controller-runtime v0.12.3
Expand Down Expand Up @@ -49,20 +53,19 @@ require (
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_golang v1.12.1 // indirect
github.com/prometheus/client_model v0.2.0 // indirect
github.com/prometheus/common v0.32.1 // indirect
github.com/prometheus/procfs v0.7.3 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/spf13/cobra v1.4.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/stretchr/testify v1.8.0 // indirect
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
go.uber.org/atomic v1.7.0 // indirect
go.uber.org/multierr v1.6.0 // indirect
golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3 // indirect
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd // indirect
golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 // indirect
golang.org/x/sys v0.0.0-20220209214540-3681064d5158 // indirect
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect
golang.org/x/text v0.3.7 // indirect
Expand All @@ -75,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.24.2 // indirect
k8s.io/apiextensions-apiserver v0.24.2 // indirect
k8s.io/component-base v0.24.2 // indirect
k8s.io/klog/v2 v2.60.1 // indirect
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJm
github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ=
github.com/ccremer/go-command-pipeline v0.18.0 h1:QJWlz+/KtBRIpv46T3zrW58w5DDaLZTnULka/7pJtQM=
github.com/ccremer/go-command-pipeline v0.18.0/go.mod h1:fhQHl6aNWFKU1qKNTd26Zn5PHlcPqTylygOu845uejA=
github.com/cenkalti/backoff v2.1.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/certifi/gocertifi v0.0.0-20191021191039-0944d244cd40/go.mod h1:sGbDF6GwGcLpkNXPUTkMRoywsNa/ol15pxFe6ERfguA=
github.com/certifi/gocertifi v0.0.0-20200922220541-2c3bb06c6054/go.mod h1:sGbDF6GwGcLpkNXPUTkMRoywsNa/ol15pxFe6ERfguA=
Expand All @@ -89,6 +90,8 @@ github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWR
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cloudscale-ch/cloudscale-go-sdk/v2 v2.0.1 h1:v0jzg+Wk2sGErKH8CTGl4+stWOJINmu1Xc3RNcBB0cM=
github.com/cloudscale-ch/cloudscale-go-sdk/v2 v2.0.1/go.mod h1:0oHKsCRkSUkAnnCBkfLxU+BaMa9s0ZicXYfYvOIJQ1E=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
Expand Down Expand Up @@ -618,6 +621,7 @@ golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd h1:O7DYs+zxREGLKzKoMQrtrEacp
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190517181255-950ef44c6e07/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
Expand Down
107 changes: 107 additions & 0 deletions operator/cloudscale/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package cloudscale

import (
"context"
"fmt"
pipeline "github.com/ccremer/go-command-pipeline"
cloudscalesdk "github.com/cloudscale-ch/cloudscale-go-sdk/v2"
cloudscalev1 "github.com/vshn/appcat-service-s3/apis/cloudscale/v1"
"github.com/vshn/appcat-service-s3/operator/steps"
"golang.org/x/oauth2"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/labels"
controllerruntime "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
)

// APIToken is the authentication token to use against cloudscale.ch API
var APIToken string

// CloudscaleClientKey identifies the cloudscale client in the context.
type CloudscaleClientKey struct{}

// CreateCloudscaleClientFn creates a new client using the API token provided.
func CreateCloudscaleClientFn(apiToken string) func(ctx context.Context) error {
return func(ctx context.Context) error {
tc := oauth2.NewClient(ctx, oauth2.StaticTokenSource(&oauth2.Token{AccessToken: apiToken}))
csClient := cloudscalesdk.NewClient(tc)
pipeline.StoreInContext(ctx, CloudscaleClientKey{}, csClient)
return nil
}
}

// CloudscaleUserKey identifies the User object from cloudscale SDK in the context.
type CloudscaleUserKey struct{}

// CreateObjectsUser creates a new objects user in the project associated with the API token.
func CreateObjectsUser(ctx context.Context) error {
csClient := steps.GetFromContextOrPanic(ctx, CloudscaleClientKey{}).(*cloudscalesdk.Client)
user := steps.GetFromContextOrPanic(ctx, ObjectsUserKey{}).(*cloudscalev1.ObjectsUser)
log := controllerruntime.LoggerFrom(ctx)

displayName := fmt.Sprintf("%s.%s", user.Namespace, user.Name)

csUser, err := csClient.ObjectsUsers.Create(ctx, &cloudscalesdk.ObjectsUserRequest{
DisplayName: displayName,
})
user.Status.UserID = csUser.ID

pipeline.StoreInContext(ctx, CloudscaleUserKey{}, csUser)
return logIfNotError(err, log, 1, "Created objects user in cloudscale")
}

// GetObjectsUser fetches an existing objects user from the project associated with the API token.
func GetObjectsUser(ctx context.Context) error {
csClient := steps.GetFromContextOrPanic(ctx, CloudscaleClientKey{}).(*cloudscalesdk.Client)
user := steps.GetFromContextOrPanic(ctx, ObjectsUserKey{}).(*cloudscalev1.ObjectsUser)
log := controllerruntime.LoggerFrom(ctx)

csUser, err := csClient.ObjectsUsers.Get(ctx, user.Status.UserID)

pipeline.StoreInContext(ctx, CloudscaleUserKey{}, csUser)
return logIfNotError(err, log, 1, "Fetched objects user in cloudscale")
}

// UserCredentialSecretKey identifies the credential Secret in the context.
type UserCredentialSecretKey struct{}

// EnsureCredentialSecret creates the credential secret.
func EnsureCredentialSecret(ctx context.Context) error {
kube := steps.GetClientFromContext(ctx)
user := steps.GetFromContextOrPanic(ctx, ObjectsUserKey{}).(*cloudscalev1.ObjectsUser)
csUser := steps.GetFromContextOrPanic(ctx, CloudscaleUserKey{}).(*cloudscalesdk.ObjectsUser)
log := controllerruntime.LoggerFrom(ctx)

secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: user.Spec.SecretRef, Namespace: user.Namespace}}

if keyErr := checkUserForKeys(csUser); keyErr != nil {
return keyErr
}

// See https://www.cloudscale.ch/en/api/v1#objects-users

_, err := controllerruntime.CreateOrUpdate(ctx, kube, secret, func() error {
secret.Labels = labels.Merge(secret.Labels, getCommonLabels(user.Name))
if secret.StringData == nil {
secret.StringData = map[string]string{}
}
secret.StringData["AWS_ACCESS_KEY_ID"] = csUser.Keys[0]["access_key"]
secret.StringData["AWS_SECRET_ACCESS_KEY"] = csUser.Keys[0]["secret_key"]
controllerutil.AddFinalizer(secret, userFinalizer)
return controllerutil.SetOwnerReference(user, secret, kube.Scheme())
})

pipeline.StoreInContext(ctx, UserCredentialSecretKey{}, secret)
return logIfNotError(err, log, 1, "Ensured credential secret", "secretName", user.Spec.SecretRef)
}

func checkUserForKeys(user *cloudscalesdk.ObjectsUser) error {
if len(user.Keys) == 0 {
return fmt.Errorf("the returned objects user has no key pairs: %q", user.ID)
}
if val, exists := user.Keys[0]["secret_key"]; exists && val == "" {
return fmt.Errorf("the returned objects user %q has no secret_key. Does the API token have enough permissions?", user.ID)
}
return nil
}
Loading

0 comments on commit 5fcf896

Please sign in to comment.