diff options
author | Martin Polden <mpolden@mpolden.no> | 2021-10-04 15:51:48 +0200 |
---|---|---|
committer | Martin Polden <mpolden@mpolden.no> | 2021-10-07 09:33:52 +0200 |
commit | 8ab6d790959d8e768f46f32e2889d471ef4f6c1d (patch) | |
tree | 42212dd02c486e1d42ce8e0b64dd1a1c669b69df /client | |
parent | bb62d8d1c9d258e9063b32594690b3080c1c4ec4 (diff) |
Add xml package
Diffstat (limited to 'client')
-rw-r--r-- | client/go/vespa/xml/config.go | 338 | ||||
-rw-r--r-- | client/go/vespa/xml/config_test.go | 297 |
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) + } +} |