logo

Starting from scratch

We will walk you through how to set up a workspace and develop a Go application with Namespace. You can find a completed version at examples/golang/01-simple. If you want to try Namespace without any setup, we recommend exploring one of our examples.

Install the CLI tool

If you haven't installed the ns command yet, refer to the Getting Started page.

Initialize your workspace

Namespace operates inside workspaces. Each contains a ns-workspace.cue file at its root which

  • defines a module (a collection of Namespace packages).
  • manages dependencies to other modules.
  • defines development environments (e.g., dev, staging, prod).

Let's create a new workspace for our playground:

mkdir ns-example && cd ns-example
ns mod init example.com/ns-example

Setup your workstation for local development

Namespace manages your development environments, whether locally or remotely, in the cloud. For this example, we'll set up a local development environment, which includes our build infrastructure and Kubernetes, running within Docker (but don't worry, you don't need to manage it).

ns prepare local

ns just wrote a devhost.textpb file. This file configures your environments defined in ns-workspace.cue. Unlike ns-workspace.cue, devhost.textpb is typically not committed as the environment configuration can be machine-specific.

Your first Namespace application

We'll develop a simple Go application that uses MinIO to store data in a S3 bucket.

Build the skeleton

Let's start with a simple Go server and create a package (directory) server. First, we define the server in a server.cue file:

// ns-example/server/server.cue
server: {
	name:        "go-server"
	integration: "go"
 
	services: {
		webapi: {
			port:    4000
			kind:    "http"
			ingress: true
		}
	}
}

Now, we can implement the code skeleton in main.go:

// ns-example/server/main.go
package main
 
import (
	"fmt"
	"log"
	"net/http"
 
	"github.com/gorilla/mux"
)
 
const httpPort = 4000 // Could be read from /namespace/config/runtime.json.
 
func main() {
	r := mux.NewRouter()
	r.PathPrefix("/").HandlerFunc(
		func(rw http.ResponseWriter, r *http.Request) {
			rw.WriteHeader(200)
			fmt.Fprintln(rw, "Hello, world!")
		})
 
	log.Printf("Listening on port: %d\n", httpPort)
	http.ListenAndServe(fmt.Sprintf(":%d", httpPort), r)
}

And finally, in our workspace root:

go mod init example.com/ns-example && go mod tidy

Start a development session

With the basic skeleton, we can start our server for the first time.

ns dev server

The argument server refers to the package containing our Go server.

When accessing http://webapi-qqu16q45ib830tir.dev.nslocal.host:40080/, we can see our Hello, world! message. Yay!

What happened under the hood?

From a single command, Namespace performed a lot of work for us:

  • set up a code watch to react to code changes
  • build our Go server with best-in-class caching
  • create the necessary Kubernetes resources: a deployment, a service, an ingress
  • stream server logs into your console
  • port-forward webapi service from Kubernetes to your local machine via nslocal.host
  • start a development UI server allowing you to explore your deployment

You can keep ns dev running while you develop the application. It will react to your code changes and rebuild/redeploy servers as needed.

Define MinIO server

In this guide we'll walk you through how to setup a MinIO server from scratch. But most of the time you'll want to use an existing definition. For example, you can find a reusable version of MinIO at library.namespace.so/oss/minio/server.

We'll create another server.cue file, this time in a minio package:

// ns-example/minio/server.cue
server: {
	name:  "my-minio-server"
	image: "minio/minio@sha256:de46799fc1ced82b784554ba4602b677a71966148b77f5028132fc50adf37b1f"
	class: "stateful"
 
	env: {
		MINIO_ROOT_USER:     "TestOnlyUser"
		MINIO_ROOT_PASSWORD: "TestOnlyPassword"
	}
 
	args: [
		"server",
		"/minio",
		"--address=:9000",
		"--console-address=:9001",
	]
 
	services: {
		api: {
			port: 9000
			kind: "http"
		}
		console: {
			port: 9001
			kind: "http"
		}
	}
 
	mounts: {
		"/minio": persistent: {
			id:   "my-minio-server-data"
			size: "10GiB"
		}
	}
}

We configured MinIO to use a persistent volume to store its data and exposed its api and console services. For simplicity, we're using hard-coded credentials. See examples/golang/02-withsecrets for an example that relies on generated secrets.

Connect your servers

It's time to make our application server call the freshly defined object-store.

// Updates to ns-example/server/server.cue
server: {
	name:        "go-server"
	integration: "go"
 
	requires: [
		"example.com/ns-example/minio",
	]
 
	env: {
		S3_ENDPOINT: fromServiceEndpoint: "example.com/ns-example/minio:api"
	}
 
	services: {
		webapi: {
			port:    4000
			kind:    "http"
			ingress: true
		}
	}
}

The requires block informs Namespace which servers are direct dependencies. When deploying your application, Namespace will

  • calculate a complete dependency graph
  • translate the graph into required Kubernetes operations
  • sort the operations topologically
  • execute the operations in order while guaranteeing that each server's dependencies are ready before deploying itself

Since ns dev is still running, all of this has already happened, and Namespace brought up MinIO for you.

For convenience, we also ask Namespace to inject the service endpoint of our dependency into an environment variable. Time to wire it up!

Final application

Given the injected endpoint, we can now connect to the data store, create a bucket and implement handlers that use it.

// ns-example/server/main.go
package main
 
import (
	"context"
	"errors"
	"fmt"
	"log"
	"net/http"
	"os"
 
	"github.com/aws/aws-sdk-go-v2/aws"
	"github.com/aws/aws-sdk-go-v2/config"
	"github.com/aws/aws-sdk-go-v2/credentials"
	"github.com/aws/aws-sdk-go-v2/service/s3"
	"github.com/aws/aws-sdk-go-v2/service/s3/types"
	"github.com/gorilla/mux"
)
 
const (
	accessKeyID     = "TestOnlyUser"
	secretAccessKey = "TestOnlyPassword"
	bucketName      = "test-bucket"
	httpPort        = 4000 // Could be read from /namespace/config/runtime.json.
)
 
func main() {
	ctx := context.Background()
 
	resolver := func(_, region string, _ ...interface{}) (aws.Endpoint, error) {
		return aws.Endpoint{
			PartitionID:   "aws",
			URL:           fmt.Sprintf("http://%s", os.Getenv("S3_ENDPOINT")),
			SigningRegion: region,
		}, nil
	}
 
	cfg, err := config.LoadDefaultConfig(ctx,
		config.WithEndpointResolverWithOptions(
			aws.EndpointResolverWithOptionsFunc(resolver)),
		config.WithCredentialsProvider(
			credentials.NewStaticCredentialsProvider(
				accessKeyID, secretAccessKey, "" /* session */)))
	if err != nil {
		log.Fatal(err)
	}
 
	cli := s3.NewFromConfig(cfg, func(o *s3.Options) { o.UsePathStyle = true })
 
	if _, err = cli.CreateBucket(ctx, &s3.CreateBucketInput{
		Bucket: aws.String(bucketName),
	}); err != nil {
		var alreadyExists *types.BucketAlreadyExists
		var alreadyOwned *types.BucketAlreadyOwnedByYou
		if !errors.As(err, &alreadyExists) && !errors.As(err, &alreadyOwned) {
			log.Fatal(err)
		}
	}
 
	r := mux.NewRouter()
	r.HandleFunc("/put", put(cli))
	r.HandleFunc("/get", get(cli))
	r.PathPrefix("/").HandlerFunc(
		func(rw http.ResponseWriter, r *http.Request) {
			rw.WriteHeader(200)
			fmt.Fprintln(rw, "Hello, world!")
		})
 
	log.Printf("Listening on port: %d\n", httpPort)
	http.ListenAndServe(fmt.Sprintf(":%d", httpPort), r)
}
 
type PutRequest struct {
	Key  string `json:"key"`
	Body []byte `json:"body"`
}
 
type GetRequest struct {
	Key string `json:"key"`
}
 
type GetResponse struct {
	Body []byte `json:"body"`
}
 
func put(cli *s3.Client) func(http.ResponseWriter, *http.Request) {
	return func(rw http.ResponseWriter, req *http.Request) {
		var parsed PutRequest
		if err := json.NewDecoder(req.Body).Decode(&parsed); err != nil {
			rw.WriteHeader(400)
			fmt.Fprintf(rw, "invalid request: %v\n", err)
			return
		}
 
		if _, err := cli.PutObject(req.Context(), &s3.PutObjectInput{
			Bucket: aws.String(bucketName),
			Key:    aws.String(parsed.Key),
			Body:   bytes.NewReader(parsed.Body),
		}); err != nil {
			rw.WriteHeader(500)
			fmt.Fprintf(rw, "failed to upload object: %v\n", err)
			return
		}
	}
}
 
func get(cli *s3.Client) func(http.ResponseWriter, *http.Request) {
	return func(rw http.ResponseWriter, req *http.Request) {
		var parsed GetRequest
		if err := json.NewDecoder(req.Body).Decode(&parsed); err != nil {
			rw.WriteHeader(400)
			fmt.Fprintf(rw, "invalid request: %v\n", err)
			return
		}
 
		out, err := cli.GetObject(req.Context(), &s3.GetObjectInput{
			Bucket: aws.String(bucketName),
			Key:    aws.String(parsed.Key),
		})
		if err != nil {
			rw.WriteHeader(500)
			fmt.Fprintf(rw, "failed to get object: %v\n", err)
			return
		}
 
		content, err := io.ReadAll(out.Body)
		if err != nil {
			rw.WriteHeader(500)
			fmt.Fprintf(rw, "failed to read object: %v\n", err)
			return
		}
 
		resp := GetResponse{
			Body: content,
		}
 
		serialized, err := json.Marshal(resp)
		if err != nil {
			rw.WriteHeader(500)
			fmt.Fprintf(rw, "internal error: %v\n", err)
			return
		}
 
		fmt.Fprintln(rw, string(serialized))
	}
}

And finally, update your go.mod file to include the new dependencies:

# In the workspace root.
go mod tidy

🎉 You did it!

You just built your first application with Namespace!

What's next?