Welcome

Welcome to the Crossplane book! This book exists for anyone who wants to learn more about the open source Crossplane project. It explores concepts from a technical perspective with a goal of enabling a reader to contribute to and extend the project for their use-case. However, it does not assume that the reader is already familiar with Go, Kubernetes, or the controller pattern. It starts with the fundamentals and builds to deeper concepts.

Where possible, we will always refer to existing guides and documentation. There are copious resources available on every topic we will cover, and there is no need to re-invent the wheel. We take the same stance on using existing frameworks and libraries. The only reason not to use existing code is if you know or suspect that it will not meet your needs in the short to medium term.

As you learn more about Crossplane, you will understand how different it is from other Kubernetes components. However, Crossplane integrates with with Kubernetes because:

  1. It allows for standardization on a single API, making it easy to integrate into your existing workflow, and to play nice with other projects.
  2. It provides a production-grade scheduling engine out-of-the-box.

If you are already familiar with Go, Kubernetes, and the controller pattern, you can jump right into learning about Crossplane. Otherwise, let's get started!

An Introduction to Go

We will not attempt to learn everything about Go in this book, mostly because that is not the primary focus. We will instead cover high-level fundamentals that allow us to knowledgeably explore Kubernetes and Crossplane. Go is renowned for its simplicity and readability, which makes it an excellent language for newcomers. Understanding a few concepts can get you up and running (and writing most of the code that we cover in this book) very quickly.

Additional Resources

An Entrypoint

Like most programming languages, Go requires an entrypoint to get a program started. You may have multiple entrypoints in a single project, but only one per package and the package name must be main. Inside of a main package, the main() function is used to start the program. However, in a non-main package, main() is not reserved as a special word and can be used as a normal function or variable.

package main

func main() {
    // do things here
}

Another special function to the main package is init(). The init() executes prior to main(), so it is commonly used for operations like reading and setting environment variables, parsing command flags, and opening database connections.

package main

import (
    "fmt"
    "os"
)

var envVars []string

func init() { // this is run first
    envVars = os.Environ()
}

func main() { // evnVars is populated when main() runs
    fmt.Println(envVars)
}

Note that neither the main() nor the init() function ever have explicit return values. If you need to exit main() early, you can use os.Exit().

Packages

Go programs are organized in packages.

  • Package names must be lowercase. You may not include underscores or mixedCaps.
  • You may only have one package per directory.
  • Packages do not have to match the name of their directory, but it is convention to do so.
  • Functions and variables that are uppercase in a package can be accessed directly by a consumer (i.e. they are "public"). These types are referred to as exported. Functions and variables that are lowercase in a package cannot be accessed outside of a package (i.e. they are "private").
  • Exported functions and variables should always have a comment block that explains their purposed and function. Most syntax checkers will flag exported arguments that do not have a comments block.
  • Packages are imported by directory path, but used by package name. For instance, if I have a package named coolpkg in the github.com/crossplane-book/crossplane-book/pkg/consumed directory, the import will look as follows:

package consumer

import "github.com/crossplane-book/crossplane-book/pkg/consumed" // imported by directory

func myFunc() {
    coolpkg.DoThing() // use by package name [note: public DoThing()]
}
  • You can alias a package import. This is especially useful when you import two packages of the same name:

package consumer

import (
  onetype "github.com/crossplane-book/crossplane-book/pkg/one/type"
  twotype "github.com/crossplane-book/crossplane-book/pkg/two/type"
)

func myFunc() {
    onetype.GetAPIVersion()
    twotype.GetAPIVersion()
}

For additional information, checkout the Go Package Style Guide.

Structs

If you have a background in Object Oriented Program, you are likely familiar with the concept of classes. Go does not provide classes, but instead defines typed collections of fields as structs. A struct can have both field and methods. Structs can also embed other structs.

Let's look at an example of a struct with fields:

package main

import "fmt"

// A Person has qualities of a human
type Person struct {
    first string
    last  string
}

func main() {
    p := &Person{ // you can create new struct instances with :=
        first: "Ada",
        last:  "Lovelace",
    }

    fmt.Println(p.first) // we can access "first" because Person is defined in this package
}

Now let's take a look at a struct with a method:

package main

import "fmt"

// A Person has qualities of a human
type Person struct {
	first string
	last  string
}

// GetFirstName returns the first name of a person
func (p *Person) GetFirstName() string { // methods are like functions but have a receiver: (p *Person)
	return p.first
}

func main() {
	p := &Person{
		first: "Ada",
		last:  "Lovelace",
	}

	fmt.Println(p.GetFirstName()) // We could still get the Person first name outside of this package because GetFirstName is public
}

Lastly, let's see what embedding a struct in another struct looks like:

package main

import "fmt"

// A Person has qualities of a human
type Person struct {
	first string
	last  string
}

// GetFirstName returns the first name of a person
func (p *Person) GetFirstName() string {
	return p.first
}

// An Athlete is a superset of a Person
type Athlete struct {
	Person
	sport string
}

func main() {
	p := &Athlete{
		Person: Person{
			first: "LeBron",
			last:  "James",
		},
		sport: "basketball",
	}

	fmt.Println(p.GetFirstName())        // We do not have to access the Person sub-struct to call its methods
	fmt.Println(p.Person.GetFirstName()) // We can explicitly reference the Person sub-struct and its methods

	// Note: if Athlete had its own GetFirstName() method, then calling
	// p.GetFirstName() would invoke it and p.Person.GetFirstName() would invoke
	// the Person implementation.
}

Interfaces

Interfaces define a set of methods that must be implemented for a struct to satisfy the interface. They are used as a form of generic programming in Go, which does not support generics, yet.

Like structs, interfaces can embed other interfaces, and a struct must satisfy all methods of the embedded interface and the parent interface. Let's look at a few examples.

A simple interface:

package main

import "fmt"

// A Being can return its first and last name
type Being interface {
    GetFirstName() string
    GetLastName() string
}

// An Person is a human
type Person struct {
    first       string
    last        string
    age         int
    nationality string
}

// GetFirstName get the first name of a Person
func (p *Person) GetFirstName() string {
    return p.first
}

// GetLastName get the first name of a Person
func (p *Person) GetLastName() string {
    return p.last
}

// A Dog is a type of animal
type Dog struct {
    name  string
    breed string
}

// GetFirstName get the first name of a Dog
func (d *Dog) GetFirstName() string {
    return d.name
}

// GetLastName get the first name of a Dog
func (d *Dog) GetLastName() string {
    // structs may implement an interface however they see fit
    return ""
}

// GetFullName returns the full name for a Being
func GetFullName(b Being) string {
    // Passing a Being means we can contruct the fullname for any type that
    // implements GetFirstName and GetLastName
    return fmt.Sprintf("%s %s", b.GetFirstName(), b.GetLastName())
}

func main() {
    p := &Person{
        first:       "Ada",
        last:        "Lovelace",
        age:         36,
        nationality: "British",
    }

    d := &Dog{
        name:  "Spot",
        breed: "Corgi",
    }

    fmt.Println(GetFullName(p)) // prints "Ada Lovelace"
    fmt.Println(GetFullName(d)) // prints "Spot "
}

An interface with an embedded interface:

package main

import "fmt"

// Aged is something that has an age
type Aged interface {
    GetAge() int
}

// A Being can return its first and last name
type Being interface {
    Aged
    GetFirstName() string
    GetLastName() string
}

// An Person is a human
type Person struct {
    first       string
    last        string
    age         int
    nationality string
}

// GetFirstName get the first name of a Person
func (p *Person) GetFirstName() string {
    return p.first
}

// GetLastName get the first name of a Person
func (p *Person) GetLastName() string {
    return p.last
}

// GetAge gets the age of a Person
func (p *Person) GetAge() int {
    return p.age
}

// A Dog is a type of animal
type Dog struct {
    name  string
    breed string
    age   int
}

// GetFirstName get the first name of a Dog
func (d *Dog) GetFirstName() string {
    return d.name
}

// GetLastName get the first name of a Dog
func (d *Dog) GetLastName() string {
    return ""
}

// GetAge gets the age of a Dog
func (d *Dog) GetAge() int {
    return d.age
}

// GetFullName returns the full name for a Being
func GetFullName(b Being) string {
    return fmt.Sprintf("%s %s", b.GetFirstName(), b.GetLastName())
}

// GetFullNameAndAge returns the full name and age for a Being
func GetFullNameAndAge(b Being) string {
    return fmt.Sprintf("%s %s: %d", b.GetFirstName(), b.GetLastName(), b.GetAge())
}

// blank identifiers are useful for ensuring a type satisfies an interface at
// compile time
var _ Being = &Person{}
var _ Being = &Dog{}

func main() {
    p := &Person{
        first:       "Ada",
        last:        "Lovelace",
        age:         36,
        nationality: "British",
    }

    d := &Dog{
        name:  "Spot",
        breed: "Corgi",
        age:   2,
    }

    fmt.Println(GetFullNameAndAge(p)) // prints "Ada Lovelace: 36"
    fmt.Println(GetFullNameAndAge(d)) // prints "Spot : 2"
}

JSON Encoding

JSON encoding may not be commonplace in most introductions to Go (although you will almost certainly encounter it early on in writing Go programs), but it is especially important for defining and generating CustomResourceDefinitions in Kubernetes (more on this later). A few important things to keep in mind when encoding and decoding JSON bytes to structs:

  • Only exported fields will be encoded/decoded to JSON.
  • Encoding a Go type to JSON is referred to as marshaling.
  • Decoding JSON bytes to a Go type is referred to as unmarshaling.
  • Struct tags are used to manipulate JSON enconding / decoding (see examples below).
  • Unexported fields are initialized to their zero value during unmarshaling.
  • An exported field in a Go struct that is not included in the JSON that is being decoded are initialized to their zero value during unmarshaling.
  • The omitempty tag specifies that a field should be omitted from the JSON encoding if the field has an empty value, defined as false, 0, a nil pointer, a nil interface value, and any empty array, slice, map, or string.
  • The - tag specifies that a field should be omitted during both encoding and decoding.

Let's look at a few examples.

Unexported field is initialized to zero value:

package main

import (
    "encoding/json"
    "fmt"
)

// A Person has qualities of a human
type Person struct {
    First string `json:"first"`
    last  string `json:"last"`
}

func main() {
    p := &Person{}

    j := []byte(`{"first": "Ada", "last": "Lovelace"}`)
    _ = json.Unmarshal(j, p)
    fmt.Println(p.First) // prints "Ada"
    fmt.Println(p.last)  // prints ""
}

Exported field with - tag is omitted:

package main

import (
    "encoding/json"
    "fmt"
)

// A Person has qualities of a human
type Person struct {
    First string `json:"first"`
    Last  string `json:"-"`
}

func main() {
    p := &Person{}

    j := []byte(`{"first": "Ada", "last": "Lovelace"}`)
    _ = json.Unmarshal(j, p)
    fmt.Println(p.First) // prints "Ada"
    fmt.Println(p.Last)  // prints ""

    p.Last = "Lovelace"

    j, _ = json.Marshal(p)
    fmt.Println(string(j)) // prints {"first":"Ada"}
}

Encoding with omitempty tag:

package main

import (
    "encoding/json"
    "fmt"
)

// A Person has qualities of a human
type Person struct {
    First string `json:"first"`
    Last  string `json:"last"`
}

// A PersonOmit has qualities of a human
type PersonOmit struct {
    First string `json:"first"`
    Last  string `json:"last,omitempty"`
}

func main() {
    p := &Person{
        First: "Ada",
        Last:  "",
    }

    j, _ := json.Marshal(p)
    fmt.Println(string(j)) // prints {"first":"Ada","last":""}

    o := &PersonOmit{
        First: "Ada",
        Last:  "",
    }

    j, _ = json.Marshal(o)
    fmt.Println(string(j)) // prints {"first":"Ada"}
}

For further detail on the JSON package, see the official documentation.

Kubernetes Overview

Kubernetes bills itself as a "production-grade container orchestrator". However, over time, it has grown to be much more than that. As you will find in the section on Crossplane, Kubernetes can provide a great deal of value without ever using it to orchestrate containerized workloads.

alt

What is a Controller?

API Types

Reconciliation

Crossplane