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:
- It allows for standardization on a single API, making it easy to integrate into your existing workflow, and to play nice with other projects.
- 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 thegithub.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.