Examples
Disclaimer
- the following examples are written in Go but any language that compiles to wasm is a first class citizen for Yoke. Writing it in Go allows us to take advantage of Kubernete's client-go package. Languages that do not compile to wasm can still be used but not benefit from Yoke's asset management. For more information see Why Wasm .
- These examples assume some familiarity with the Go toolchain, and that yoke has already been installed.
Writing your first Flight
The most simple implementation of a Flight, is to simply write the resources you want to standard out:
// example.go
package main
import "fmt"
var deployment = `
apiVersion: apps/v1
kind: Deployment
metadata:
name: example-app
labels:
app: example-app
spec:
replicas: 2
selector:
matchLabels:
app: example-app
template:
metadata:
labels:
app: example-app
spec:
containers:
- name: example-app-container
image: nginx:latest # Replace with your actual container image
ports:
- containerPort: 80
`
func main() {
fmt.Println(deployment)
}
Compile it:
GOOS=wasip1 GOARCH=wasm go build -o example.wasm ./example.go
Deploy it:
yoke takeoff example ./example.wasm
And we're done! With this example, you have defined a valid Flight, compiled it to wasm, and had yoke deploy the first revision of a release named example.
However, although illustrative as a first example, we haven't gained any of the advantages of using code to describe our k8s packages. Indeed the previous example is equivalent to feeding the raw text directly to yoke:
yoke takeoff example < example.yaml
Using Client-Go to build Flights
Now that we understand that Flights are simply programs that output the resources as JSON/YAML, we can start representing our resources as Go values utilizing our own types and abstractions, or those provided for us by other libraries.
As it happens client-go has a package named applyconfigurations that allows us to build representations of core K8s resources. Let's use it to redefine our deployment from the earlier example, and to add a service for it.
The previous example was as unidiomatic as possible. This time we can breakdown the construction of resources into their own functions and create any signature or contract that we want.
// example.go
package main
import (
"encoding/json"
"fmt"
"os"
"github.com/davidmdm/yoke/pkg/flight"
v1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/util/intstr"
appsv1 "k8s.io/client-go/applyconfigurations/apps/v1"
corev1 "k8s.io/client-go/applyconfigurations/core/v1"
metav1 "k8s.io/client-go/applyconfigurations/meta/v1"
)
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run() error {
var (
release = flight.Release() // the first argument passed to yoke takeoff; ie: yoke takeoff RELEASE foo
namespace = flight.Namespace() // the value of the flag namespace during takeoff; ie: yoke takeoff -namespace NAMESPACE ...
labels = map[string]string{"app": release}
)
resources := []any{
CreateDeployment(DeploymentConfig{
Name: release,
Namespace: namespace,
Labels: labels,
Replicas: 2,
}),
CreateService(ServiceConfig{
Name: release,
Namespace: namespace,
Labels: labels,
Port: 80,
TargetPort: 80,
}),
}
return json.NewEncoder(os.Stdout).Encode(resources)
}
type DeploymentConfig struct {
Name string
Namespace string
Labels map[string]string
Replicas int32
}
func CreateDeployment(cfg DeploymentConfig) *appsv1.DeploymentApplyConfiguration {
container := corev1.Container().
WithName(cfg.Name).
WithImage("alpine:latest").
WithCommand("watch", "echo", "hello", "world")
podTemplate := corev1.PodTemplateSpec().
WithLabels(cfg.Labels).
WithSpec(corev1.PodSpec().WithContainers(container))
spec := appsv1.DeploymentSpec().
WithReplicas(cfg.Replicas).
WithSelector(metav1.LabelSelector().WithMatchLabels(cfg.Labels)).
WithTemplate(podTemplate)
return appsv1.Deployment(cfg.Name, cfg.Namespace).
WithLabels(cfg.Labels).
WithSpec(spec)
}
type ServiceConfig struct {
Name string
Namespace string
Labels map[string]string
Port int32
TargetPort int
}
func CreateService(cfg ServiceConfig) *corev1.ServiceApplyConfiguration {
servicePort := corev1.ServicePort().
WithProtocol(v1.ProtocolTCP).
WithPort(cfg.Port).
WithTargetPort(intstr.FromInt(cfg.TargetPort))
serviceSpec := corev1.ServiceSpec().
WithSelector(cfg.Labels).
WithType(v1.ServiceTypeClusterIP).
WithPorts(servicePort)
return corev1.Service(cfg.Name, cfg.Namespace).
WithLabels(cfg.Labels).
WithSpec(serviceSpec)
}
Repeating the steps as in the first example, we:
Compile it:
GOOS=wasip1 GOARCH=wasm go build -o example.wasm ./example.go
Deploy it:
yoke takeoff example ./example.wasm
Take-aways
We have created a second revision of our release called example. It contains both a deployment and a service. Furthermore, it comes with all the advantages we expect from code. It has type-safety, IntelliSense, and documentation is a click away. We may break things out as a reusable functions, create our own contracts, and write tests. The world is your oyster.
In this example, we used the client-go applyconfigurations package to build our resource values. However you may use the types resource types directly, or create your own simplified representations.
Configuring your Flight
In the previous examples, our Flights have been static. Every invokation will return the exact same output. This is a valid approach, especially when you are reusing low-altitude Flight functions and want to take advantage of type-safety and features provided by your coding environment. Changes can be applied by re-compiling different configurations of the Flight and versioning those assets through time.
However, sometimes we wish to have one compiled program that can be invoked at deploy time with different settings. To achieve this, Yoke supports instantiating wasm executables with both flags and standard input.
Let's look at a simplified version of the deployment example from above and let us consider how we could configure the replica count.
// example.go
package main
import (
"encoding/json"
"os"
appsv1 "k8s.io/client-go/applyconfigurations/apps/v1"
)
// This example is not fully runnable
// As the deployment is missing too many properties to be useful.
// We will only be interested in learning to configure the Replicas value.
func main() {
deployment := appsv1.Deployment("example-app", "").
WithSpec(appsv1.DeploymentSpec().WithReplicas(2))
json.NewEncoder(os.Stdout).Encode(deployment)
}
Using Flags
// example.go
package main
import (
"encoding/json"
"flag"
"os"
appsv1 "k8s.io/client-go/applyconfigurations/apps/v1"
)
func main() {
replicas := flag.Int("replicas", 2, "replica count for deployment")
flag.Parse()
deployment := appsv1.Deployment("example-app", "").
WithSpec(appsv1.DeploymentSpec().WithReplicas(int32(*replicas)))
json.NewEncoder(os.Stdout).Encode(deployment)
}
After compiling it we can invoke it from the Yoke CLI with flags:
yoke takeoff example ./example.wasm -- --replicas=5
Using Stdin
This approach is most similar to how Helm uses `values.yaml` to pass configuration, as using stdin is equivalent to passing a file.
// example.go
package main
import (
"encoding/json"
"io"
"os"
appsv1 "k8s.io/client-go/applyconfigurations/apps/v1"
)
type Values struct {
Replicas int32 `json:"replicas"`
}
func main() {
// Default Values
values := Values{Replicas: 2}
// Here we need to check that the error is not io.EOF as this is what you will get if
// no standard input is used.
if err := json.NewDecoder(os.Stdin).Decode(&values); err != nil && err != io.EOF {
panic(err)
}
deployment := appsv1.Deployment("example-app", "").
WithSpec(appsv1.DeploymentSpec().WithReplicas(values.Replicas))
json.NewEncoder(os.Stdout).Encode(deployment)
}
After compiling it we can invoke it from the Yoke CLI with stdin:
yoke takeoff example ./example.wasm <<< '{"replicas":5}'
or
yoke takeoff example ./example.wasm < values.json