Search This Blog

Showing posts with label golang. Show all posts
Showing posts with label golang. Show all posts

Wednesday, 9 October 2019

Running containerized traffic lights in Raspberry Pi Kubernetes cluster

My cluster setup


The incentive behind this exercise was to understand how to set up deployment of an application talking to Raspberry Pi GPIOs when running the application in a container within a Kubernetes cluster.

Hardware


  • four Raspberry Pi 4 Model B (4Gb of memory)
  • 4 microSD cards (3 x 32Gb, 1 x 64Gb):
    • Samsung EVO Plus 32 GB microSDHC 
    • SanDisk Ultra 32GB microSDHC Memory Card 
  • 5 port USB power supply (PoE hat currently too expensive at £18 per one board)
  • 5 port Ethernet switch (no PoE functionality):
    • TTP-Link LS1005G 5-Port Desktop/Wallmount Gigabit Ethernet Switch
  • 4 USB cables (the best speed you can afford)
  • 4 Ethernet cables
    • Multi Cable SLIM FLAT 1m Cat6 RJ45 Ethernet Network Patch Lan cable
  • 2 traffic lights
  • 3 lights:
    • 3-Piece Set Ky 009 5050 3 Colour SDM RGB 3 Color LED Module for Arduino
  • 3 GPIO header extensions:
    • T-Type GPIO Extension Board Module with 40 Pin Rainbow Ribbon Cable
  • Cluster tower:
    • MakerFun Pi Rack Case for Raspberry Pi 4 Model B,Raspberry Pi 3 B+ Case with Cooling Fan and Heatsink, 4 Layers Acrylic Case Stackable

Software

  • Balena Etcher
  • Raspbian lite (at the time of writing buster)
  • k3s or full blown k8s
    • k3s lightweight kubernetes setup:
      • either manually
      • using k3sup
    • k8s set up using kubeadmin
You will not need it for this project, but if you want to set up a Raspberry Pi Go development environment, it is possible to download a Go binary for the ARM architecture. uname -a command tells what architecture  the board has:

armv6l, armv7l .......... go1.13.1.linux-armv6l.tar.gz (at the time of writing), for Raspbian OS

arm64 ....................... go1.13.1.linux-arm64.tar.gz, for other 64bit OSes

There are articles documenting setup of a Raspberry Pi Kubernetes cluster (see Links), so I shall not detail how I went about it here. Perhaps YARPCA in the future. One thing I found I needed to do to be able to access the cluster locally using the kubectl command, was to set the KUBECONFIG environment variable, even if I had the right (Raspberry Pi cluster) context in the default file ~/.kube/config. This was not obvious given the Kubernetes documentation.

I tried three ways of Kubernetes installation: using kubeadmin, k3sup and manual installation of k3s. I have now two clusters, one with the full blown Kubernetes container management system, one with the lighter k3s.

Each of the Raspberry Pi boards has a traffic light or an LED light (3 changing colours, ie connects to 3 pins + ground) connected to its GPIOs.

Fun with traffic lights


The traffic-lights Go code, Dockerfile and kubernetes manifests can be found on github.

main.go
package main

import (
 "fmt"
 "os"
 "os/signal"
 "syscall"
 "time"

 rpio "github.com/stianeikeland/go-rpio/v4"
)

func main() {
 fmt.Printf("Starting traffic lights at %s\n", time.Now())

 // Opens memory range for GPIO access in /dev/mem
 if err := rpio.Open(); err != nil {

  fmt.Printf("Cannot access GPIO: %s\n", time.Now())

  fmt.Println(err)
  os.Exit(1)
 }

 // Get the pin for each of the lights (refers to the bcm2835 layout)
 redPin := rpio.Pin(2)
 yellowPin := rpio.Pin(3)
 greenPin := rpio.Pin(4)

 fmt.Printf("GPIO pins set up: %s\n", time.Now())

 // Set the pins to output mode
 redPin.Output()
 yellowPin.Output()
 greenPin.Output()

 fmt.Printf("GPIO output set up: %s\n", time.Now())

 // Clean up on ctrl-c and turn lights out
 c := make(chan os.Signal, 1)
 signal.Notify(c, os.Interrupt, syscall.SIGTERM)
 go func() {
  <-c

  fmt.Printf("Switching off traffic lights at %s\n", time.Now())

  redPin.Low()
  yellowPin.Low()
  greenPin.Low()

  os.Exit(0)
 }()

 defer rpio.Close()

 // Turn lights off to start.
 redPin.Low()
 yellowPin.Low()
 greenPin.Low()

 fmt.Printf("All traffic lights switched off at %s\n\n", time.Now())

 // Let's loop now ...
 for {
  fmt.Println("\tSwitching lights on and off")

  // Red
  redPin.High()
  time.Sleep(time.Second * 2)

  // Yellow
  redPin.Low()
  yellowPin.High()
  time.Sleep(time.Second)

  // Green
  yellowPin.Low()
  greenPin.High()
  time.Sleep(time.Second * 2)

  // Yellow
  greenPin.Low()
  yellowPin.High()
  time.Sleep(time.Second * 2)

  // Yellow off
  yellowPin.Low()
 }

}


I went through several stages to learn and fully understand each scenario and to ensure all was working as supposed to.


Stage 1 - running the application directly on Raspberry Pi


After setting up the application dependencies using Go modules, I compiled a binary for the ARM architecture:

    GOOS=linux GOARCH=arm GOARM=7 go build -o trafficlights_arm7 .

Then transferred it to each of the boards to test the pins. The IP addresses are set up to be static in my router, eg the master is 192.168.1.92 etc. The hostnames are fixed as well, my master is raspberrypi-k3s-a.

    scp trafficlights_arm7 pi@192.168.1.92:.

I then sshed to each Raspberry Pi node and ran the traffic lights application

   ./trafficlights_arm7

All confirmed as working satisfactorily, I embarked on stage two, containerizing the traffic lights and running them on the Raspberry Pi nodes in containers.


Stage 2 - running the application in a container on Raspberry Pi


Dockerfile
FROM golang:1.13.1-buster as builder
WORKDIR /app
COPY . .

ENV GOARCH arm
ENV GOARM 7
ENV GOOS linux
RUN ["go", "build", "-o", "trafficlights", "."]

FROM scratch
WORKDIR /app
COPY --from=builder /app/trafficlights /app
CMD ["/app/trafficlights"]


I am using a two stage image build to have a lightweight final Docker image.
I create the image running the following command in the root of the traffic-lights git repo:

     docker build -t "forbiddenforrest/traffic-lights:0.1.0-armv7" .

I then log into my Docker registry (docker login) and push the image

     docker push forbiddenforrest/traffic-lights:0.1.0-armv7

Now I can ssh into one of my Raspberry Pi nodes and run a container based on the pushed traffic-lights image. The docker command is available when using the full version of kubernetes. When using k3s, docker is not available out of thew box as k3s uses containerd. docker can be downloaded separately (sudo apt-get install docker.io).
Manipulating the traffic lights using a containerized application did not prove straightforward. The container needs access to the Raspberry Pi node hardware, which is not given by default. I searched for a solution, came across some, but none worked for me. So I first needed to understand better how it works on the Raspberry Pi side and on the Docker side, then a spot of trial and error approach.

The result that worked for me:

     docker run --rm -it --device /dev/mem --device /dev/gpiomem  forbiddenforrest/traffic-lights:0.1.0-armv7

alternatively

     docker run --rm -it --device=/dev/mem --device=/dev/gpiomem  forbiddenforrest/traffic-lights:0.1.0-armv7

alternatively

     docker run --rm -it --device=/dev/mem:/dev/mem \
--device=/dev/gpiomem:/dev/gpiomem forbiddenforrest/traffic-lights:0.1.0-armv7


This (based on what I found when researching) did not work:

     docker run --rm -it --privileged forbiddenforrest/traffic-lights:0.1.0-armv7

     docker container run --rm -it --privileged --device=/dev/mem:/dev/mem --device=/dev/gpiomem:/dev/gpiomem -v /sys:/sys  forbiddenforrest/traffic-lights:0.1.0-armv7

     docker container run --rm -it --privileged  -v /sys:/sys forbiddenforrest/traffic-lights:0.1.0-armv7


All confirmed working, I moved to the next stage of running traffic-lights in a pod.

Stage 3 - running the application in a Pod in Raspberry Pi Kubernetes cluster


traffic_lights_pod.yaml
apiVersion: v1
kind: Pod
metadata:
  name: traffic-lights
  labels:
    app: traffic-lights
spec:
  securityContext:
    runAsUser: 1000
    runAsGroup: 997
    fsGroup: 15
  containers:
  - name: traffic-lights
    image: forbiddenforrest/traffic-lights:0.1.0-armv7
    securityContext:
      privileged: true

The securityContext needs to be set correctly both for the pod and the containers running in that pod:

runAsUser ..... 1000 (pi user)
runAsGroup ... 997  (group of /dev/mem, special character file which mirrors the main memory)
fsGroup .......... 15    (group of /dev/gpiomem, special character file which mirrors the memory associated with the GPIO device. Some volumes (storage) are owned and are writable by this GID.)

from
pi@raspberrypi-k3s-a:~ $ ls -l /dev/mem
crw-r----- 1 root kmem 1, 1 Oct  6 23:17 /dev/mem
pi@raspberrypi-k3s-a:~ $ ls -l /dev/gpiomem 
crw-rw---- 1 root gpio 247, 0 Oct  6 23:17 /dev/gpiomem
pi@raspberrypi-k3s-a:~ $ cat /etc/group |grep mem
kmem:x:15:
pi@raspberrypi-k3s-a:~ $ cat /etc/group |grep gpio
gpio:x:997:pi

Dealing with pods,  we are now fully dealing with the cluster, ie the traffic-lights application, when the pod is created, will be scheduled on one of the worker nodes. If the pod is killed and a new one created, it will be scheduled randomly by kubernetes. The master node is tainted not to be considered for scheduling.

To have a visual proof of the scheduling, I created a bash script for creating and deleting pods. Each of the worker nodes is connected to a light. When a pod gets deployed on a node, the lights connected to that particular Raspberry Pi board start working.

kubectl apply -f traffic_lights_pod.yaml
sleep 6
kubectl delete pod traffic-lights
kubectl apply -f traffic_lights_pod.yaml
sleep 6
kubectl delete pod traffic-lights
kubectl apply -f traffic_lights_pod.yaml
sleep 6
kubectl delete pod traffic-lights
kubectl apply -f traffic_lights_pod.yaml
sleep 6
kubectl delete pod traffic-lights

It took about 10 seconds to remove a deleted pod. If a pod is created directly in the cluster, kubernetes will not recreate it automatically when it is deleted. Let's deploy the traffic-lights using Kubernetes Deployment - my last Stage 4.


Stage 4 - running the application as a Deployment in Raspberry Pi Kubernetes cluster


traffic_lights_deploy.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: traffic-lights
  labels:
    app: traffic-lights
spec:
  replicas: 1
  selector:
    matchLabels:
      app: traffic-lights
  template:
    metadata:
      name: traffic-lights
      labels:
        app: traffic-lights
    spec:
      securityContext:
        runAsUser: 1000
        runAsGroup: 997
        fsGroup: 15
      containers:
      - name: traffic-lights
        image: forbiddenforrest/traffic-lights:0.1.0-armv7
        securityContext:
          privileged: true

The Deployment template contains the same Pod specification without apiVersion and kind information. The Deployment prescribes there should be one pod running at any time.

To see which worker the traffic-lights application is running on, I have the following script:

start_stop_deploy.sh
#!/bin/bash
echo "kubectl apply -f traffic_lights_deploy.yaml"
echo "then 10 rounds of pod deletion"
echo ""
echo "Start ..."
sleep 5
kubectl apply -f traffic_lights_deploy.yaml

for i in 1 2 3 4 5 6 7 8 9 10
do
    echo "round $i"
    echo "creating pod at `date`"
    sleep 4
    echo "deleting pod at `date`"
    kubectl delete pod -l 'app=traffic-lights'
done
kubectl delete deployments.apps/traffic-lights

echo "End ..."

It takes about 5 seconds for a new pod to start when one is deleted. A new pod is already in place while kubernetes is tidying up the deleted one.


And now a bit of cinematography



Saturday, 12 May 2018

Dockerization of Go web applications using alpine base image on Ubuntu 16.04

Using the attractively small alpine image as a base for dockerizing a Go web application on an Ubuntu host, does not work if the application binary is built using the traditional:

GOOS=linux GOARCH=amd64  go build


FROM alpine:latest

RUN mkdir /app
WORKDIR /app
COPY service /app
EXPOSE 50051
ENTRYPOINT ["./service"]

The image builds without a problem, but running a container:

docker run -p 50051:50051 author-service

results in the following error:

standard_init_linux.go:178: exec user process caused "no such file or directory"

The reason behind this lies in the alpine OS missing dependencies that the Go application by default needs. In particular it is the Go net package, that by default requires cgo to enable dynamic runtime linking to C libraries, which the alpine does not provide.

SOLUTION


a)
On some systems (like Ubuntu) it is possible to use the network without cgo, so we can use the the CGO_ENABLED flag to change the default:


CGO_ENABLED=0 GOOS=linux GOARCH=amd64  go build .

b)
Alternatively it is possible to enforce using Go implementation of net dependencies, netgo: 

GOOS=linux GOARCH=amd64  go build -tags netgo -a -v .

c)
If you for any reason don't want to use either of the above, just use debien instead of alpine.


Wednesday, 10 May 2017

Dockerization of a Golang application

There are several possibilities how to write a Dockerfile for a Golang application. Which one to choose depends on dependencies the application needs.

If the application uses just built in packages, it is possible to have a very minimalistic Dockerfile.


             FROM scratch

             COPY ./goaws /

             CMD ["/goaws"]


Using scratch means we are not basing our image on anything, there is no operating system. This is possible if we do not need to perform any shell operations that require coperation of the OS like mkdir etc.

-------------------------------------------------------------------------------------------------

If there are non built in dependencies, there there are several approaches possible.

a) The first approach bases the new Docker image on an official golang one, downloads all the dependencies, builds the Go binary and starts the application.

There are several gotchas to watch out for:

The application dependencies, whether internal (packages provided within the application) or external (3rd party packages from the Go public repository) need to be in the right place for the build to be able to find them. The paths searched depend on $GOPATH and $GOROOT environment variables. In the golang:1.8 image OS, the $GOPATH is /go. I created a /go/src/github.com/tamarakaufler/goaws directory which allows the Go compiler to find all internal packages during the build. The go get command will install the external packages and we are good to go (excuse the pun).


             FROM golang:1.8

             RUN mkdir -p /go/src/github.com/tamarakaufler/goaws

             COPY . /go/src/github.com/tamarakaufler/goaws/

             RUN go get github.com/ghodss/yaml

             RUN go get github.com/gorilla/mux

             WORKDIR /go/src/github.com/tamarakaufler/goaws

             RUN go build .

             CMD ["./goaws"]


b) Another option is to create an intermediate image based on an official golang one, install various 3rd party packages that your applications need and copy over internal packages. Then:

             FROM my_golang

             COPY ./goaws /

             CMD ["/goaws"]

where my_golang's Dockerfile is:

           FROM golang:1.8

           RUN mkdir -p /go/src/github.com/tamarakaufler/goaws
           COPY ./app/conf/config.go /go/src/github.com/tamarakaufler/goaws/app/conf/
           COPY ./app/router/router.go /go/src/github.com/tamarakaufler/goaws/app/router/

           RUN go get github.com/ghodss/yaml
           RUN go get github.com/gorilla/mux

Saturday, 19 March 2016

Go implementation of Fibonacci - two ways

There are various ways how to go about the implementation of Fibonacci, or other series, in Go. Some are common to different languages, like a recursive function or using a closure. One approach is Go specific, taking advantage of its concept of channels. I shall be showing and explaining the closure based and channel based implementations.

Closure based implementation

Closures are functions that "close over"  the scope of the parent namespace and, as a result, have access to and remember variables in the parent scope, even after they finished running. As a result, closures have been used abundantly to implement, for instance, counter incrementing.


Channel based implementation

Go introduced a concept of channels, that serve for communication between goroutines, the Go approach to concurrency. If a channel is created without specifying its capacity, it is blocking and the processing will stop until the channel receives or can send data (depending on whether a sender or a receiver).


Full program


 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
53
54
55
56
57
58
59
60
61
62
63
64
package main

import (
 "fmt"
 "time"
)

func main() {
        max := 15

 fmt.Println("================== FIBONACCI CLOSURE implementation ==================")
 start := time.Now()
 fmt.Println("%s", start)

 f := fib_closure()

 for n := 0; n < max; n++ {
  fmt.Printf(">>> %d\n", n)
  fmt.Println(f(n))
 }
 end := time.Now()
 fmt.Printf("Calculation finished in %s \n", end.Sub(start))

 fmt.Println("================== FIBONACCI: CHANNEL implementation ==================")
 fmt.Println("%s", start)

 start = time.Now()
 c := fib_chan()

 for n := 0; n < max; n++ {
  fmt.Printf(">>> %d\n", n)
  fmt.Println(<-c)
 }
 end = time.Now()
 fmt.Printf("Calculation finished in %s \n", end.Sub(start)) 

}

func fib_closure() func(int) int {
 i, j := 1, 1

 return func(n int) int {
  switch {
  case n == 0 || n == 1:
   return 1
  default:
   i, j = j, i+j
  }

  return i
 }
}

func fib_chan() chan int {
 c := make(chan int)

 go func() {
  for i, j := 0, 1; ; i, j = i+j, i {
   c <- i
  }
 }()

 return c
}


Explanation


Shared by both approaches:


We decide how many Fibonnaci numbers we want calculated.

Performance comparison        max := 15

We are also interested in the performance of each approach, so we shall do some benchmaking using start := time.Now(), end :+ time.Now() and subtraction end.Sub(start).

Fibonacci - closure implementation


func fib_closure() func(int) int {

i, j := 1, 1 ..... for 0 and 1, the fibonnaci is 1

return func(n int) int { .... We are returning a closure, that on each subsequent invocation remembers the                                                            values  of i and j.

switch {
case n == 0 || n == 1:
return 1
default:
i, j = j, i+j  ........ The previous state is remembered (i, j) and the new one calculated (j, i+j)
}

return i
}
}

Then doing the calculations: 

fmt.Println("================== FIBONACCI CLOSURE implementation ==================")
start := time.Now()
fmt.Println("%s", start)

f := fib_closure() ............ We initialize the state by running the fib_closure()  and creating a reference to its                                                        return function/closure.

for n := 0; n < max; n++ {
fmt.Printf(">>> %d\n", n)
fmt.Println(f(n)) ..... The closure f(n) remembers calculations of < n
}
end := time.Now()
fmt.Printf("Calculation finished in %s \n", end.Sub(start))


Fibonacci - channel implementation


func fib_chan() chan int {
c := make(chan int)

go func() {
for i, j := 0, 1; ; i, j = i+j, i { ..... the logic of the fibonnaci calculation
c <- i  ................................. the result is sent to the channel
}
}()

return c
}

Then doing the calculations:

fmt.Println("================== FIBONACCI: CHANNEL implementation ==================")
fmt.Println("%s", start)

start = time.Now()
c := fib_chan() ................ Go channel which returns the Fibonacci result sent to it

for n := 0; n < max; n++ {
fmt.Printf(">>> %d\n", n)
fmt.Println(<-c) ...... the channel returns received the calculated value
}
end = time.Now()
fmt.Printf("Calculation finished in %s \n", end.Sub(start)) 


Performance comparison


On different runs, I got the following runtimes:

Closure - Channel

      222 - 140 us
      253 - 168 us
      255 - 175 us

The difference is due to what else the OS was doing at the time when the program ran. The closure implementation is roughly 1.5 times slower. (The recursive approach is the slowest.)

Tuesday, 15 December 2015

Golang Tips and Tricks


Installation of Go crypto/ssh module (Ubuntu 15.04)

 

go get -v code.google.com/p/go.crypto/ssh

code.google.com/p/go.crypto (download)
go: missing Mercurial command. See http://golang.org/s/gogetcmd
package code.google.com/p/go.crypto/ssh: exec: "hg": executable file not found in $PATH

This failure in downloading the crypto/ssh package is due to the fact the crypto/ssh repo was moved to golang.org/x/crypto/ssh.

go get -v  golang.org/x/crypto/ssh

Fetching https://golang.org/x/crypto/ssh?go-get=1
Parsing meta tags from https://golang.org/x/crypto/ssh?go-get=1 (status code 200)
...
...
# golang.org/x/crypto/ssh
/home/tamara/.gvm/pkgsets/go1.3.3/global/src/golang.org/x/crypto/ssh/keys.go:492: undefined: crypto.Signer

This error is fixed by upgrading Go to at least 1.4.

go get -v  golang.org/x/crypto/ssh

golang.org/x/crypto/curve25519
golang.org/x/crypto/ssh

Setting $GOPATH (golang workspace) when installing Go with gvm

 

Ubuntu comes with an obsolete Go version. We can use gvm, Go version manager, to install a higher Go version by following steps in the http://www.hostingadvice.com/how-to/install-golang-on-ubuntu/

Starting to use a particular Go version (gvm use go1.4.2) resets some of the Go environment variables:

go env

GOARCH="amd64"
GOBIN=""
GOCHAR="6"
GOEXE=""
GOHOSTARCH="amd64"
GOHOSTOS="linux"
GOOS="linux"
GOPATH="/home/tamara/.gvm/pkgsets/go1.4.2/global"
GORACE=""
GOROOT="/home/tamara/.gvm/gos/go1.4.2"
GOTOOLDIR="/home/tamara/.gvm/gos/go1.4.2/pkg/tool/linux_amd64"
CC="gcc"
GOGCCFLAGS="-fPIC -m64 -pthread -fmessage-length=0"
CXX="g++"
CGO_ENABLED="1"

gvm pkgset list

gvm go package sets (go1.4.2)

=>  global

gvm pkgset create tamarakaufler
gvm pkgset use tamarakaufler

=> Now using version go1.4.2@tamarakaufler
gvm pkgset list
gvm go package sets (go1.4.2)

    global
=>  tamarakaufler
 
gvm pkgenv tamarakaufler
opens the default editor for configuration of the 
project specific workspace. Edit the lines 12 
and 16 accordingly (bold parts)  

# line 12
export GOPATH; GOPATH="/home/tamara/.gvm/pkgsets/go1.4.2/tamarakaufler:$HOME/programming/go:$GOPATH"

# line 16
export PATH; PATH="/home/tamara/.gvm/pkgsets/go1.4.2/tamarakaufler/bin:${GVM_OVERLAY_PREFIX}/bin:$HOME/programming/go/bin:${PATH}"

Sunday, 1 November 2015

Literals in Go

BACKGROUND

  • Go source code is written in Unicode characters, encoded in UTF-8
  • Literals represent fixed, ie constant, values
  • There are two types of literals in Go, related to textual context: rune literals and string literals.

RUNE literals

Rune is an integer (uint32, 4 byte binary number), representing a Unicode code point, a unique identifier of a character within a particular encoding. In UTF-8, the most common Unicode encoding, a code point can represent a sequence of one to 4 bytes. ASCII (representing the old English and a group of unprintable characters) has 128 code points; extended ASCII, representing most Western languages 256.

Rune literal is expressed as one or more characters in single quotes, excluding unquoted single quotes and newlines.

STRING literals

String literal is a concatenation of characters, a character sequence. There are two types: interpreted string literals and raw string literals.
Interpreted string literals are enclosed in double quotes with any character allowed, except for unquoted double quote and newline.

Raw string literals are enclosed in back quotes. The character sequence can contain newlines and backslashes have no special escaping effect. Any carriage returns (\r) within the literal are stripped from the raw string.

Example


 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
package main

import (
 "log"
 "net/http"
)

func main() {

 http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
  w.Write([]byte('<html><head>
    <title>Chatting away</title>
    </head><body>Go and chat!</body></html>
    '))
 })

 if err := http.ListenAndServe(":3333", nil); err != nil {
  log.Fatal("Starting server : ", err)
 }

}

The above code produces errors on lines 11 and 14: rune literal not terminated.

Keeping the single quotes and putting the whole string value on one line results in:  illegal rune literal. Single quotes are reserved for characters, so no surprise there.

Changing the single quotes to double quotes still results in error, this time: string literal not terminated. You may find it puzzling, just as I did, until you realize this is an interpreted string literal (because enclosed in double quotes) and recall these cannot contain newlines.

There are two options here:
  1. Put the whole string value on one line:
            "<html><head><title>Chatting away</title></head><body>Go and chat!</body></html>"
  1. Use back quotes, ie use a raw string literal, rather than an interpreted one. This will allow to spread the string value over several lines, because the back quoted raw string literal removes the carriage return: 
             `<html>
              <head><title>Chatting away</title></head>
              <body>Go and chat!</body>
              </html>`