In the previous post, we scaffolded the skeleton of our operator and also added functionality to create github repository when the custom resource GithubRepository is created.

In this post, we are going to build on top of that and add functionalities to update and delete the created github repository.

Posts in the series 

Update function 

End to end test 

We again start with a failing end to end test:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
Context("When GithubRepository resource updated", func() {
	var name, namespace string
	var githubRepository pnguyeniov1.GithubRepository

	BeforeEach(func() {
		namespace = "default"
		name = fmt.Sprintf("existing-repo-%d", rand.IntnRange(1000, 9000))
	})

	It("should update github repository using github API", func() {
		githubRepository = pnguyeniov1.GithubRepository{
			ObjectMeta: metav1.ObjectMeta{
				Name:      name,
				Namespace: namespace,
			},
			Spec: pnguyeniov1.GithubRepositorySpec{
				Owner:       "test-owner",
				Repo:        name,
				Description: "initial description",
			},
		}

		err := k8sClient.Create(context.TODO(), &githubRepository)
		Expect(err).NotTo(HaveOccurred())

		Eventually(func() {
			var resource pnguyeniov1.GithubRepository
			err := k8sClient.Get(context.TODO(), types.NamespacedName{
				Namespace: namespace,
				Name:      name,
			}, &resource)
			Expect(err).NotTo(HaveOccurred())
			Expect(resource.Status.Successful).To(BeTrue())
		}, 10*time.Second, time.Second).Should(Succeed())

		githubRepository.Spec.Description = "updated description"
		k8sClient.Update(context.TODO(), &githubRepository)

		history, err := smocker.RequestByPath(fmt.Sprintf("/repos/test-owner/%s", name), "PATCH", "name", name)
		Expect(err).NotTo(HaveOccurred())

		Expect(history.Response.Status).To(Equal(200))
		Expect(history.Response.Body.(map[string]interface{})["name"]).To(Equal(name))
	})

	AfterEach(func() {
		err := k8sClient.Delete(context.TODO(), &githubRepository)
		if err != nil {
			logf.Log.Error(err, "failed to delete GithubRepository", "name", name)
		}
	})
})

A few things to notice:

  • The test uses the name existing-repo-{random-id} for custom resource and git repo name. This is intended because smocker was setup to return 200 response for any GET request with repo name existing-repo in the path. We are telling smocker to tell github client that that repo exists in github
  • For now we only support updating repo description. Owner and repo name cannot be updated and we will add validation for that in subsequent post.
  • In previous end to end test, we have a helper functiion waitUntilSuccessful() to keep checking until controller sets status of the custom resource to successful. Turn out Ginkgo comes with Omega matcher library that has Eventually that does exactly the same thing. We can replace our custom code with this:
1
2
3
4
5
6
7
8
9
Eventually(func() {
	var resource pnguyeniov1.GithubRepository
	err := k8sClient.Get(context.TODO(), types.NamespacedName{
		Namespace: namespace,
		Name:      name,
	}, &resource)
	Expect(err).NotTo(HaveOccurred())
	Expect(resource.Status.Successful).To(BeTrue())
}, 10*time.Second, time.Second).Should(Succeed())

Run the test and we should see an error on no smocker request in history matching our expectation.

Refactor githubapi package 

In the previous post, we decided githubapi.CreateRepository() does a checking of whether given repository exists in github before creating it. Now we need to consider how to implement the same check for update function.

We have several options:

  • Move logic of checking whether a repo exists in github to another function in githubapi, say githubapi.RepositoryExists(). Controller will use that to decide to call githubapi.CreateRepository() or githubapi.UpdateRepository(). Controller will use the same way to check before doing deletion later too.
  • Or update the existing function githubapi.CreateRepository() to do the checking and decide whether to create or update. The function name will need to be updated to githubapi.CreateOrUpdateRepository(). Later when implementing delete function, we will need to repeat the same check in that function too.

I choose option 2 simply because I don’t want to put too much logic in controller.

I will skip over the renaming of githubapi.CreateRepository() to githubapi.CreateOrUpdateRepository(). It can be done easily with IDE.

We have a test in previous post to check that githubapi.CreateRepository() not doing a POST to github if repository exists. Let’s update that test to check for github PATCH (repo update) call:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
t.Run("call github api to update when repository exists", func(t *testing.T) {
	baseUrl := "http://localhost:8088"
	uploadUrl := "http://localhost:8088"
	token := "valid-token"
	repoName := fmt.Sprintf("existing-repo-%d", rand.IntnRange(1000, 9000))
	api := New(context.TODO(), baseUrl, uploadUrl, token)

	err := api.CreateOrUpdateRepository("test-owner", repoName, "some-description")

	require.NoError(t, err)
	history, err := smocker.RequestByPath(fmt.Sprintf("/repos/test-owner/%s", repoName), "PATCH", "name", repoName)
	require.NoError(t, err)
	require.Equal(t, http.StatusOK, history.Response.Status)
	require.Equal(t, repoName, history.Response.Body.(map[string]interface{})["name"])
})

to pass the test, we need to add a smocker definition that responds to github PATCH request:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
- request:
    path:
      matcher: ShouldMatch
      value: /api/v3/repos/.*/existing-repo.*
    method: PATCH
  dynamic_response:
    engine: go_template
    script: |-
      status: 200
      headers:
        Content-Type: [application/json]
      body: >
        {
          "id": 1296269,
          "name": "{{ regexReplaceAll "/api/v3/repos/.*/(existing-repo.*)" .Request.Path "${1}" }}",
          "full_name": "{{.Request.Path | replace "/api/v3/repos/" "" }}"
        }

and modify githubapi.CreateOrUpdateRepository() to call github update:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// ...
if resp.StatusCode == http.StatusNotFound {
	_, _, err = client.Repositories.Create(a.ctx, "", &repository)
	if err != nil {
		return fmt.Errorf("failed to create repository %s: error: %v", repo, err)
	}
} else {
	_, _, err = client.Repositories.Edit(a.ctx, owner, repo, &repository)
	if err != nil {
		return fmt.Errorf("failed to update repository %s: error: %v", repo, err)
	}
}
// ...

That also passes the end to end test

Delete function 

End to end test 

A similar end to end test:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
Context("When GithubRepository resource deleted", func() {
	var name, namespace string
	var githubRepository pnguyeniov1.GithubRepository

	BeforeEach(func() {
		namespace = "default"
		name = fmt.Sprintf("existing-repo-%d", rand.IntnRange(1000, 9000))
	})

	It("should delete github repository using github API", func() {
		githubRepository = pnguyeniov1.GithubRepository{
			ObjectMeta: metav1.ObjectMeta{
				Name:      name,
				Namespace: namespace,
			},
			Spec: pnguyeniov1.GithubRepositorySpec{
				Owner:       "test-owner",
				Repo:        name,
				Description: "initial description",
			},
		}

		err := k8sClient.Create(context.TODO(), &githubRepository)
		Expect(err).NotTo(HaveOccurred())

		Eventually(func() {
			var resource pnguyeniov1.GithubRepository
			err := k8sClient.Get(context.TODO(), types.NamespacedName{
				Namespace: namespace,
				Name:      name,
			}, &resource)
			Expect(err).NotTo(HaveOccurred())
			Expect(resource.Status.Successful).To(BeTrue())
		}, 10*time.Second, time.Second).Should(Succeed())

		err = k8sClient.Delete(context.TODO(), &githubRepository)
		Expect(err).NotTo(HaveOccurred())

		Eventually(func() {
			history, err := smocker.RequestByPath(fmt.Sprintf("/repos/test-owner/%s", name), "DELETE", "", "")
			Expect(err).NotTo(HaveOccurred())
			Expect(history.Response.Status).To(Equal(204))
		}, 10*time.Second, time.Second).Should(Succeed())
	})
})

This time we just delete the GithubRepository custom resouce and expect a DELETE request is recorded in smocker

Implement deletion 

Back to githubapi test, we need to add tests for a new function DeleteRepository(). Start with happy path test:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
t.Run("call github api to delete when repository exists", func(t *testing.T) {
	baseUrl := "http://localhost:8088"
	uploadUrl := "http://localhost:8088"
	token := "valid-token"
	repoName := fmt.Sprintf("existing-repo-%d", rand.IntnRange(1000, 9000))
	api := New(context.TODO(), baseUrl, uploadUrl, token)

	err := api.DeleteRepository("test-owner", repoName)

	require.NoError(t, err)
	history, err := smocker.RequestByPath(fmt.Sprintf("/repos/test-owner/%s", repoName), "DELETE", "name", repoName)
	require.NoError(t, err)
	require.Equal(t, http.StatusNoContent, history.Response.Status)
})

Add mock definition to respond to DELETE request and simplest code to pass the test:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
func (a *api) DeleteRepository(owner, repo string) error {
	ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: a.token})
	oauthClient := oauth2.NewClient(a.ctx, ts)
	client, err := github.NewEnterpriseClient(a.baseUrl, a.uploadUrl, oauthClient)
	if err != nil {
		return fmt.Errorf("failed to create github client: %v", err)
	}

	client.Repositories.Delete(a.ctx, owner, repo)
	return nil
}

Now add test to make sure it returns error correctly when something’s wrong:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
t.Run("return error when failed to delete repository", func(t *testing.T) {
	baseUrl := "http://localhost:8088"
	uploadUrl := "http://localhost:8088"
	token := ""
	repoName := fmt.Sprintf("existing-repo-%d", rand.IntnRange(1000, 9000))
	api := New(context.TODO(), baseUrl, uploadUrl, token)

	err := api.DeleteRepository("test-owner", repoName)

	require.Error(t, err)
	require.Contains(t, err.Error(), fmt.Sprintf("failed to delete repository %s", repoName))
})

Add smocker mock definition and update code to return error if any:

1
2
3
4
5
6
7
// ...
_, err = client.Repositories.Delete(a.ctx, owner, repo)
if err != nil {
	return fmt.Errorf("failed to delete repository %s: error: %v", repo, err)
}

return nil

Last test is to make sure we don’t call delete if repo not exists in github:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
t.Run("not call github api to delete when repository not exists", func(t *testing.T) {
	baseUrl := "http://localhost:8088"
	uploadUrl := "http://localhost:8088"
	token := "valid-token"
	repoName := fmt.Sprintf("delete-repo-%d", rand.IntnRange(1000, 9000))
	api := New(context.TODO(), baseUrl, uploadUrl, token)

	err := api.DeleteRepository("test-owner", repoName)

	require.NoError(t, err)
	_, err = smocker.RequestByPath(fmt.Sprintf("/repos/test-owner/%s", repoName), "DELETE", "", "")
	require.Error(t, err)
	require.Contains(t, err.Error(), "no smocker history matching")
})

Update code to check whether repo exists:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// ...
_, resp, err := client.Repositories.Get(a.ctx, owner, repo)
if err != nil && resp.StatusCode != http.StatusNotFound {
	return fmt.Errorf("failed to get repository %s: error: %v", repo, err)
}

if resp.StatusCode == http.StatusNotFound {
	return nil
}
// ...

Before we move on, let’s do some refactoring and clean up in githubapi package. Parameters like baseUrl, uploadUrl are only needed when creating github client, so we can move it to New() function:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
func New(ctx context.Context, baseUrl, uploadUrl, token string) (*api, error) {
	client, err := newGithubClient(ctx, baseUrl, uploadUrl, token)
	if err != nil {
		return nil, fmt.Errorf("failed to create github client: %v", err)
	}

	return &api{
		ctx:    ctx,
		client: client,
	}, nil
}

that’s a breaking change since the function returns additional error now. Thankfully it’s simple to fix

next change is to move all hardcoded smocker url http://localhost:8088 in api_test.go to a const in the test. It can be updated to take the value from environment variable later.

We are done with githubapi package change. The remaining is to use it in the controller: when GithubRepository custom resource is deleted, controller should call githubapi.DeleteRepository() to delete given repository

Add finalizer to controller 

Kubernetes and operators use finalizer to implement deletion logic. Further details can be found in:

The kubebuilder book even gives us a template/pattern to follow, so it’s straightforward in our case:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// githubrepository_controller.go
// ...
if resource.ObjectMeta.DeletionTimestamp.IsZero() {
	if !controllerutil.ContainsFinalizer(&resource, FinalizerName) {
		// setup finalizer
		controllerutil.AddFinalizer(&resource, FinalizerName)
		if err := r.Update(ctx, &resource); err != nil {
			return ctrl.Result{}, fmt.Errorf("unable to add finalizer: %v", err)
		}
	}

	if err := api.CreateOrUpdateRepository(resource.Spec.Owner, resource.Spec.Repo, resource.Spec.Description); err != nil {
		return ctrl.Result{}, err
	}

	resource.Status.Successful = true
	if err := r.Status().Update(ctx, &resource); err != nil {
		return ctrl.Result{}, fmt.Errorf("unable to update status of GithubRepository resource: %v", err)
	}

	logger.Info("repository created/updated")
} else {
	// deletion timestamp is set, resource is being deleted
	if controllerutil.ContainsFinalizer(&resource, FinalizerName) {
		if err := api.DeleteRepository(resource.Spec.Owner, resource.Spec.Repo); err != nil {
			return ctrl.Result{}, err
		}

		// done finalization logic, remove finalizer
		controllerutil.RemoveFinalizer(&resource, FinalizerName)
		if err := r.Update(ctx, &resource); err != nil {
			return ctrl.Result{}, fmt.Errorf("unable to remove finalizer: %v", err)
		}

		logger.Info("repository deleted")
	}
}
// ...

With that we finish our implementation and pass the end to end test

Test it out 

  • Create github personal access token with repo and delete_repo scope
  • Export github token as GITHUB_API_TOKEN environment variable in your terminal
  • Run make install to install CRD to your local kubernetes cluster
  • Run make run to run controller in your terminal
  • Update sample CR at ./config/samples/pnguyen.io_v1_githubrepository.yaml
  • In another terminal, run kubectl apply -f ./config/samples/pnguyen.io_v1_githubrepository.yaml
  • Verify that new github repository created
  • Update description of the repository at ./config/samples/pnguyen.io_v1_githubrepository.yaml to something else
  • Run kubectl apply -f ./config/samples/pnguyen.io_v1_githubrepository.yaml
  • Verify that the repository description in github is updated
  • Run kubectl delete -f ./config/samples/pnguyen.io_v1_githubrepository.yaml
  • Verify that the repository is deleted in github