summaryrefslogtreecommitdiffstats
path: root/client
diff options
context:
space:
mode:
authorMartin Polden <mpolden@mpolden.no>2021-10-04 15:51:48 +0200
committerMartin Polden <mpolden@mpolden.no>2021-10-07 09:33:52 +0200
commit8ab6d790959d8e768f46f32e2889d471ef4f6c1d (patch)
tree42212dd02c486e1d42ce8e0b64dd1a1c669b69df /client
parentbb62d8d1c9d258e9063b32594690b3080c1c4ec4 (diff)
Add xml package
Diffstat (limited to 'client')
-rw-r--r--client/go/vespa/xml/config.go338
-rw-r--r--client/go/vespa/xml/config_test.go297
2 files changed, 635 insertions, 0 deletions
diff --git a/client/go/vespa/xml/config.go b/client/go/vespa/xml/config.go
new file mode 100644
index 00000000000..c9b65822e7f
--- /dev/null
+++ b/client/go/vespa/xml/config.go
@@ -0,0 +1,338 @@
+package xml
+
+import (
+ "bufio"
+ "bytes"
+ "encoding/xml"
+ "fmt"
+ "io"
+ "regexp"
+ "strconv"
+ "strings"
+)
+
+var DefaultDeployment Deployment
+
+func init() {
+ defaultDeploymentRaw := `<deployment version="1.0">
+ <prod>
+ <region active="true">aws-us-east-1c</region>
+ </prod>
+</deployment>`
+ d, err := ReadDeployment(strings.NewReader(defaultDeploymentRaw))
+ if err != nil {
+ panic(err)
+ }
+ DefaultDeployment = d
+}
+
+// Deployment represents the contents of a deployment.xml file.
+type Deployment struct {
+ Root xml.Name `xml:"deployment"`
+ Version string `xml:"version,attr"`
+ Instance []Instance `xml:"instance"`
+ Prod Prod `xml:"prod"`
+ rawXML bytes.Buffer
+}
+
+type Instance struct {
+ Prod Prod `xml:"prod"`
+}
+
+type Prod struct {
+ Regions []Region `xml:"region"`
+}
+
+type Region struct {
+ Name string `xml:",chardata"`
+ Active bool `xml:"active,attr"`
+}
+
+func (d Deployment) String() string { return d.rawXML.String() }
+
+// Replace replaces any elements of name found under parentName with data.
+func (s *Deployment) Replace(parentName, name string, data interface{}) error {
+ rewritten, err := Replace(&s.rawXML, parentName, name, data)
+ if err != nil {
+ return err
+ }
+ newXML, err := ReadDeployment(strings.NewReader(rewritten))
+ if err != nil {
+ return err
+ }
+ *s = newXML
+ return nil
+}
+
+// Services represents the contents of a services.xml file.
+type Services struct {
+ Root xml.Name `xml:"services"`
+ Container []Container `xml:"container"`
+ Content []Content `xml:"content"`
+ rawXML bytes.Buffer
+}
+
+type Container struct {
+ Root xml.Name `xml:"container"`
+ ID string `xml:"id,attr"`
+ Nodes Nodes `xml:"nodes"`
+}
+
+type Content struct {
+ ID string `xml:"id,attr"`
+ Nodes Nodes `xml:"nodes"`
+}
+
+type Nodes struct {
+ Count string `xml:"count,attr"`
+ Resources *Resources `xml:"resources,omitempty"`
+}
+
+type Resources struct {
+ Vcpu string `xml:"vcpu,attr"`
+ Memory string `xml:"memory,attr"`
+ Disk string `xml:"disk,attr"`
+}
+
+func (s Services) String() string { return s.rawXML.String() }
+
+// Replace replaces any elements of name found under parentName with data.
+func (s *Services) Replace(parentName, name string, data interface{}) error {
+ rewritten, err := Replace(&s.rawXML, parentName, name, data)
+ if err != nil {
+ return err
+ }
+ newXML, err := ReadServices(strings.NewReader(rewritten))
+ if err != nil {
+ return err
+ }
+ *s = newXML
+ return nil
+}
+
+func (r Resources) String() string {
+ return fmt.Sprintf("vcpu=%s,memory=%s,disk=%s", r.Vcpu, r.Memory, r.Disk)
+}
+
+// ReadDeployment reads deployment.xml from reader r.
+func ReadDeployment(r io.Reader) (Deployment, error) {
+ var deployment Deployment
+ var rawXML bytes.Buffer
+ dec := xml.NewDecoder(io.TeeReader(r, &rawXML))
+ if err := dec.Decode(&deployment); err != nil {
+ return Deployment{}, err
+ }
+ deployment.rawXML = rawXML
+ return deployment, nil
+}
+
+// ReadServices reads services.xml from reader r.
+func ReadServices(r io.Reader) (Services, error) {
+ var services Services
+ var rawXML bytes.Buffer
+ dec := xml.NewDecoder(io.TeeReader(r, &rawXML))
+ if err := dec.Decode(&services); err != nil {
+ return Services{}, err
+ }
+ services.rawXML = rawXML
+ return services, nil
+}
+
+// Regions returns given region names as elements.
+func Regions(names ...string) []Region {
+ var regions []Region
+ for _, z := range names {
+ regions = append(regions, Region{Name: z, Active: true})
+ }
+ return regions
+}
+
+// ParseResources parses nodes resources from string s.
+func ParseResources(s string) (Resources, error) {
+ parts := strings.Split(s, ",")
+ if len(parts) != 3 {
+ return Resources{}, fmt.Errorf("invalid resources: %q", s)
+ }
+ vcpu, err := parseResource("vcpu", parts[0])
+ if err != nil {
+ return Resources{}, err
+ }
+ memory, err := parseResource("memory", parts[1])
+ if err != nil {
+ return Resources{}, err
+ }
+ disk, err := parseResource("disk", parts[2])
+ if err != nil {
+ return Resources{}, err
+ }
+ return Resources{Vcpu: vcpu, Memory: memory, Disk: disk}, nil
+}
+
+// ParseNodeCount parses a node count range from string s.
+func ParseNodeCount(s string) (int, int, error) {
+ parseErr := fmt.Errorf("invalid node count: %q", s)
+ n, err := strconv.Atoi(s)
+ if err == nil {
+ return n, n, nil
+ }
+ if strings.HasPrefix(s, "[") && strings.HasSuffix(s, "]") {
+ parts := strings.Split(s[1:len(s)-1], ",")
+ if len(parts) != 2 {
+ return 0, 0, parseErr
+ }
+ min, err := strconv.Atoi(parts[0])
+ if err != nil {
+ return 0, 0, parseErr
+ }
+ max, err := strconv.Atoi(parts[1])
+ if err != nil {
+ return 0, 0, parseErr
+ }
+ return min, max, nil
+ }
+ return 0, 0, parseErr
+}
+
+// ValidProdRegion returns whether string s is a valid production region.
+func ValidProdRegion(s string) bool {
+ switch s {
+ case "aws-us-east-1c", "aws-us-west-2a",
+ "aws-eu-west-1a", "aws-ap-northeast-1a":
+ return true
+ }
+ return false
+}
+
+func parseResource(field, s string) (string, error) {
+ parts := strings.SplitN(s, "=", 2)
+ if len(parts) != 2 || parts[0] != field {
+ return "", fmt.Errorf("invalid value for %s field: %q", field, s)
+ }
+ return parts[1], nil
+}
+
+// ReplaceRaw finds all elements of name in rawXML and replaces their contents with value.
+func ReplaceRaw(rawXML, name, value string) string {
+ startElement := "<" + name + ">"
+ endElement := "</" + name + ">"
+ re := regexp.MustCompile(regexp.QuoteMeta(startElement) + ".*" + regexp.QuoteMeta(endElement))
+ return re.ReplaceAllString(rawXML, startElement+value+endElement)
+}
+
+// Replace looks for an element name in the XML read from reader r, appearing inside a element named parentName.
+//
+// Any matching elements found are replaced with data. If parentName contains an ID selector, e.g. "email#my-id", only
+// the elements inside the parent element with the attribute id="my-id" are replaced.
+//
+// If data is nil, any matching elements are removed instead of replaced.
+func Replace(r io.Reader, parentName, name string, data interface{}) (string, error) {
+ var buf bytes.Buffer
+ dec := xml.NewDecoder(r)
+ enc := xml.NewEncoder(&buf)
+ enc.Indent("", " ")
+
+ parts := strings.SplitN(parentName, "#", 2)
+ id := ""
+ if len(parts) > 1 {
+ parentName = parts[0]
+ id = parts[1]
+ }
+
+ foundParent := false
+ replacing := false
+ done := false
+ for {
+ token, err := dec.Token()
+ if err == io.EOF {
+ break
+ } else if err != nil {
+ return "", err
+ }
+ token = joinNamespace(token)
+ if isEndElement(parentName, token) {
+ foundParent = false
+ done = false
+ }
+ if _, ok := getStartElement(parentName, id, token); ok {
+ foundParent = true
+ }
+ if foundParent {
+ if isEndElement(name, token) {
+ replacing = false
+ continue
+ }
+ replacableElement, ok := getStartElement(name, "", token)
+ if ok {
+ replacing = true
+ }
+ if replacing {
+ if !done && data != nil {
+ replacableElement.Attr = nil // Clear any existing attributes as given data should contain the wanted ones
+ if err := enc.EncodeElement(data, replacableElement); err != nil {
+ return "", err
+ }
+ done = true
+ }
+ continue
+ }
+ }
+ if err := enc.EncodeToken(token); err != nil {
+ return "", err
+ }
+ }
+ if err := enc.Flush(); err != nil {
+ return "", err
+ }
+ var sb strings.Builder
+ scanner := bufio.NewScanner(&buf)
+ for scanner.Scan() {
+ line := scanner.Text()
+ // Skip lines containing only whitespace
+ if strings.TrimSpace(line) == "" {
+ continue
+ }
+ sb.WriteString(line)
+ sb.WriteRune('\n')
+ }
+ if err := scanner.Err(); err != nil {
+ return "", err
+ }
+ return sb.String(), nil
+}
+
+func joinNamespace(token xml.Token) xml.Token {
+ // Hack to work around the broken namespace support in Go
+ // https://github.com/golang/go/issues/13400
+ if startElement, ok := token.(xml.StartElement); ok {
+ attr := make([]xml.Attr, 0, len(startElement.Attr))
+ for _, a := range startElement.Attr {
+ if a.Name.Space != "" {
+ a.Name.Space = ""
+ a.Name.Local = "xmlns:" + a.Name.Local
+ }
+ attr = append(attr, a)
+ }
+ startElement.Attr = attr
+ return startElement
+ }
+ return token
+}
+
+func getStartElement(name, id string, token xml.Token) (xml.StartElement, bool) {
+ startElement, ok := token.(xml.StartElement)
+ if !ok {
+ return xml.StartElement{}, false
+ }
+ matchingID := id == ""
+ for _, attr := range startElement.Attr {
+ if attr.Name.Local == "id" && attr.Value == id {
+ matchingID = true
+ }
+ }
+ return startElement, startElement.Name.Local == name && matchingID
+}
+
+func isEndElement(name, token xml.Token) bool {
+ endElement, ok := token.(xml.EndElement)
+ return ok && endElement.Name.Local == name
+}
diff --git a/client/go/vespa/xml/config_test.go b/client/go/vespa/xml/config_test.go
new file mode 100644
index 00000000000..9d18636473b
--- /dev/null
+++ b/client/go/vespa/xml/config_test.go
@@ -0,0 +1,297 @@
+package xml
+
+import (
+ "reflect"
+ "strings"
+ "testing"
+)
+
+func TestReplaceDeployment(t *testing.T) {
+ in := `
+<deployment version="1.0">
+ <prod>
+ <region active="true">us-north-1</region>
+ <region active="false">eu-north-2</region>
+ </prod>
+</deployment>`
+
+ out := `<deployment version="1.0">
+ <prod>
+ <region active="true">eu-south-1</region>
+ <region active="true">us-central-1</region>
+ </prod>
+</deployment>
+`
+ regions := Regions("eu-south-1", "us-central-1")
+ assertReplace(t, in, out, "prod", "region", regions)
+}
+
+func TestReplaceDeploymentWithInstance(t *testing.T) {
+ in := `
+<deployment version="1.0">
+ <instance id="default">
+ <prod>
+ <region active="true">us-north-1</region>
+ </prod>
+ </instance>
+ <instance id="beta">
+ <prod>
+ <region active="true">eu-south-1</region>
+ </prod>
+ </instance>
+</deployment>`
+
+ out := `<deployment version="1.0">
+ <instance id="default">
+ <prod>
+ <region active="true">us-central-1</region>
+ <region active="true">eu-west-1</region>
+ </prod>
+ </instance>
+ <instance id="beta">
+ <prod>
+ <region active="true">us-central-1</region>
+ <region active="true">eu-west-1</region>
+ </prod>
+ </instance>
+</deployment>
+`
+ regions := Regions("us-central-1", "eu-west-1")
+ assertReplace(t, in, out, "prod", "region", regions)
+}
+
+func TestReplaceServices(t *testing.T) {
+ in := `
+<services xmlns:deploy="vespa" xmlns:preprocess="properties">
+ <container id="qrs">
+ <search/>
+ <document-api/>
+ <nodes count="2">
+ <resources vcpu="4" memory="8Gb" disk="50Gb"/>
+ </nodes>
+ </container>
+ <content id="music">
+ <redundancy>2</redundancy>
+ <nodes count="3">
+ <resources vcpu="8" memory="32Gb" disk="200Gb"/>
+ </nodes>
+ <documents>
+ <document type="music"/>
+ </documents>
+ </content>
+</services>
+`
+
+ out := `<services xmlns:deploy="vespa" xmlns:preprocess="properties">
+ <container id="qrs">
+ <search></search>
+ <document-api></document-api>
+ <nodes count="4">
+ <resources vcpu="2" memory="4Gb" disk="50Gb"></resources>
+ </nodes>
+ </container>
+ <content id="music">
+ <redundancy>2</redundancy>
+ <nodes count="3">
+ <resources vcpu="8" memory="32Gb" disk="200Gb"></resources>
+ </nodes>
+ <documents>
+ <document type="music"></document>
+ </documents>
+ </content>
+</services>
+`
+ nodes := Nodes{Count: "4", Resources: &Resources{Vcpu: "2", Memory: "4Gb", Disk: "50Gb"}}
+ assertReplace(t, in, out, "container#qrs", "nodes", nodes)
+}
+
+func TestReplaceServicesEmptyResources(t *testing.T) {
+ in := `<services xmlns:deploy="vespa" xmlns:preprocess="properties">
+ <container id="movies">
+ <search></search>
+ <document-api></document-api>
+ <nodes count="4"/>
+ </container>
+</services>
+`
+ out := `<services xmlns:deploy="vespa" xmlns:preprocess="properties">
+ <container id="movies">
+ <search></search>
+ <document-api></document-api>
+ <nodes count="5">
+ <resources vcpu="4" memory="8Gb" disk="100Gb"></resources>
+ </nodes>
+ </container>
+</services>
+`
+ nodes := Nodes{Count: "5", Resources: &Resources{Vcpu: "4", Memory: "8Gb", Disk: "100Gb"}}
+ assertReplace(t, in, out, "container#movies", "nodes", nodes)
+}
+
+func TestReplaceRemovesElement(t *testing.T) {
+ in := `
+<deployment version="1.0">
+ <prod>
+ <region active="true">eu-south-1</region>
+ <region active="true">us-central-1</region>
+ <test>us-central-1</test>
+ </prod>
+</deployment>`
+
+ out := `<deployment version="1.0">
+ <prod>
+ <region active="true">eu-south-1</region>
+ <region active="true">us-central-1</region>
+ </prod>
+</deployment>
+`
+ assertReplace(t, in, out, "prod", "test", nil)
+}
+
+func TestReplaceRaw(t *testing.T) {
+ in := `
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
+ http://maven.apache.org/xsd/maven-4.0.0.xsd">
+ <modelVersion>4.0.0</modelVersion>
+ <groupId>ai.vespa.cloud.docsearch</groupId>
+ <artifactId>vespacloud-docsearch</artifactId>
+ <packaging>container-plugin</packaging>
+ <version>1.0.0</version>
+
+ <parent>
+ <groupId>com.yahoo.vespa</groupId>
+ <artifactId>cloud-tenant-base</artifactId>
+ <version>[7,999)</version>
+ </parent>
+
+ <properties>
+ <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+ <tenant>tenant</tenant>
+ <application>app</application>
+ <instance>instance</instance>
+ </properties>
+
+</project>
+`
+ replacements := map[string]string{
+ "tenant": "vespa-team",
+ "application": "music",
+ "instance": "default",
+ }
+ rewritten := in
+ for element, value := range replacements {
+ rewritten = ReplaceRaw(rewritten, element, value)
+ }
+
+ out := `
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
+ http://maven.apache.org/xsd/maven-4.0.0.xsd">
+ <modelVersion>4.0.0</modelVersion>
+ <groupId>ai.vespa.cloud.docsearch</groupId>
+ <artifactId>vespacloud-docsearch</artifactId>
+ <packaging>container-plugin</packaging>
+ <version>1.0.0</version>
+
+ <parent>
+ <groupId>com.yahoo.vespa</groupId>
+ <artifactId>cloud-tenant-base</artifactId>
+ <version>[7,999)</version>
+ </parent>
+
+ <properties>
+ <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+ <tenant>vespa-team</tenant>
+ <application>music</application>
+ <instance>default</instance>
+ </properties>
+
+</project>
+`
+ if rewritten != out {
+ t.Errorf("got:\n%s\nwant:\n%s\n", rewritten, out)
+ }
+}
+
+func TestReadServicesNoResources(t *testing.T) {
+ s := `
+<services xmlns:deploy="vespa" xmlns:preprocess="properties">
+ <container id="qrs">
+ <nodes count="2">
+ <resources vcpu="4" memory="8Gb" disk="50Gb"/>
+ </nodes>
+ </container>
+ <content id="music">
+ <redundancy>2</redundancy>
+ <nodes count="3"/>
+ <documents>
+ <document type="music"/>
+ </documents>
+ </content>
+</services>
+`
+ services, err := ReadServices(strings.NewReader(s))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := services.Content[0].Nodes.Resources; got != nil {
+ t.Errorf("got %+v, want nil", got)
+ }
+}
+
+func TestParseResources(t *testing.T) {
+ assertResources(t, "foo", Resources{}, true)
+ assertResources(t, "vcpu=2,memory=4Gb", Resources{}, true)
+ assertResources(t, "memory=4Gb,vcpu=2,disk=100Gb", Resources{}, true)
+ assertResources(t, "vcpu=2,memory=4Gb,disk=100Gb", Resources{Vcpu: "2", Memory: "4Gb", Disk: "100Gb"}, false)
+}
+
+func TestParseNodeCount(t *testing.T) {
+ assertNodeCount(t, "2", 2, 2, false)
+ assertNodeCount(t, "[4,8]", 4, 8, false)
+
+ assertNodeCount(t, "foo", 0, 0, true)
+ assertNodeCount(t, "[foo,bar]", 0, 0, true)
+}
+
+func assertReplace(t *testing.T, input, want, parentElement, element string, data interface{}) {
+ got, err := Replace(strings.NewReader(input), parentElement, element, data)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got != want {
+ t.Errorf("got:\n%s\nwant:\n%s\n", got, want)
+ }
+}
+
+func assertNodeCount(t *testing.T, input string, wantMin, wantMax int, wantErr bool) {
+ min, max, err := ParseNodeCount(input)
+ if wantErr {
+ if err == nil {
+ t.Errorf("want error for input %q", input)
+ }
+ return
+ }
+ if min != wantMin || max != wantMax {
+ t.Errorf("got min = %d, max = %d, want min = %d, max = %d", min, max, wantMin, wantMax)
+ }
+}
+
+func assertResources(t *testing.T, input string, want Resources, wantErr bool) {
+ got, err := ParseResources(input)
+ if wantErr {
+ if err == nil {
+ t.Errorf("want error for %q", input)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(want, got) {
+ t.Errorf("got %+v, want %+v", got, want)
+ }
+}