aboutsummaryrefslogtreecommitdiffstats
path: root/config-model-api/src/main/java/com/yahoo/config/application/api/ValidationOverrides.java
blob: f69cfa6d4c525ea12b11c1f610a2e65c691ff67d (plain) (blame)
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
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
package com.yahoo.config.application.api;

import com.yahoo.io.IOUtils;
import com.yahoo.text.XML;
import org.w3c.dom.Element;

import java.io.IOException;
import java.io.Reader;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;

/**
 * A set of allows which suppresses specific validations in limited time periods.
 * This is useful to be able to complete a deployment in cases where the application
 * owner believes that the changes to be deployed have acceptable consequences.
 * Immutable.
 *
 * @author bratseth
 */
public class ValidationOverrides {

    public static final ValidationOverrides empty = new ValidationOverrides(List.of(), "<validation-overrides/>");

    private final List<Allow> overrides;

    private final String xmlForm;

    /** Creates a validation overrides which does not have an xml form */
    public ValidationOverrides(List<Allow> overrides) {
        this(overrides, null);
    }

    private ValidationOverrides(List<Allow> overrides, String xmlForm) {
        this.overrides = List.copyOf(overrides);
        this.xmlForm = xmlForm;
    }

    /** Throws a ValidationException unless all given validation is overridden at this time */
    public void invalid(Map<ValidationId, ? extends Collection<String>> messagesByValidationId, Instant now) {
        Map<ValidationId, Collection<String>> disallowed = new HashMap<>(messagesByValidationId);
        disallowed.keySet().removeIf(id -> allows(id, now));
        if ( ! disallowed.isEmpty())
            throw new ValidationException(disallowed);
    }

    /** Throws a ValidationException unless this validation is overridden at this time */
    public void invalid(ValidationId validationId, String message, Instant now) {
        if ( ! allows(validationId, now))
            throw new ValidationException(validationId, message);
    }

    public boolean allows(String validationIdString, Instant now) {
        Optional<ValidationId> validationId = ValidationId.from(validationIdString);
        if (validationId.isEmpty()) return false; // unknown id -> not allowed
        return allows(validationId.get(), now);
    }

    /** Returns whether the given (assumed invalid) change is allowed by this at the moment */
    public boolean allows(ValidationId validationId, Instant now) {
        for (Allow override : overrides) {
            if (override.allows(validationId, now))
                return true;
        }
        return false;
    }

    /** Validates overrides (checks 'until' date') */
    public boolean validate(Instant now) {
        for (Allow override : overrides) {
            if (now.plus(Duration.ofDays(30)).isBefore(override.until))
                throw new IllegalArgumentException("validation-overrides is invalid: " + override +
                                                   " is too far in the future: Max 30 days is allowed");
        }
        return false;
    }

    /** Returns the XML form of this, or null if it was not created by fromXml, nor is empty */
    public String xmlForm() { return xmlForm; }

    public static String toAllowMessage(ValidationId id) {
        return "To allow this add <allow until='yyyy-mm-dd'>" + id + "</allow> to validation-overrides.xml" +
               ", see https://docs.vespa.ai/en/reference/validation-overrides.html";
    }

    /**
     * Returns a ValidationOverrides instance with the content of the given Reader.
     *
     * @param reader the reader containing a validation-overrides XML structure
     * @return a ValidationOverrides from the argument
     * @throws IllegalArgumentException if the validation-allows.xml file exists but is invalid
     */
    public static ValidationOverrides fromXml(Reader reader) {
        try {
            return fromXml(IOUtils.readAll(reader));
        } catch (IOException e) {
            throw new IllegalArgumentException("Could not read validation-overrides", e);
        }
    }

    /**
     * Returns a ValidationOverrides instance with the content of the given XML string.
     * An empty ValidationOverrides is returned if the argument is empty.
     *
     * @param xmlForm the string which optionally contains a validation-overrides XML structure
     * @return a ValidationOverrides from the argument
     * @throws IllegalArgumentException if the validation-allows.xml file exists but is invalid
     */
    public static ValidationOverrides fromXml(String xmlForm) {
        if ( xmlForm.isEmpty()) return ValidationOverrides.empty;

        // Assume valid structure is ensured by schema validation
        Element root = XML.getDocument(xmlForm).getDocumentElement();
        List<ValidationOverrides.Allow> overrides = new ArrayList<>();
        for (Element allow : XML.getChildren(root, "allow")) {
            try {
                Instant until = LocalDate.parse(allow.getAttribute("until"), DateTimeFormatter.ISO_DATE)
                        .atStartOfDay().atZone(ZoneOffset.UTC).toInstant()
                        .plus(Duration.ofDays(1)); // Make the override valid *on* the "until" date
                Optional<ValidationId> validationId = ValidationId.from(XML.getValue(allow));
                // skip unknown ids as they may be valid for other model versions
                validationId.ifPresent(id -> overrides.add(new Allow(id, until)));
            } catch (RuntimeException e) {
                throw new IllegalArgumentException(e);
            }
        }
        return new ValidationOverrides(overrides, xmlForm);
    }

    /** A validation override which allows a particular change. Immutable. */
    public static class Allow {

        private final ValidationId validationId;
        private final Instant until;

        public Allow(ValidationId validationId, Instant until) {
            this.validationId = validationId;
            this.until = until;
        }

        public boolean allows(ValidationId validationId, Instant now) {
            return this.validationId.equals(validationId) && now.isBefore(until);
        }

        @Override
        public String toString() { return "allow '" + validationId + "' until " + until; }

    }

    /**
     * A deployment validation exception.
     * Deployment validations can be {@link ValidationOverrides overridden} based on their id.
     */
    public static class ValidationException extends IllegalArgumentException {

        static final long serialVersionUID = 789984668;

        private ValidationException(ValidationId validationId, String message) {
            super(validationId + ": " + message + ". " + toAllowMessage(validationId));
        }

        private ValidationException(Map<ValidationId, Collection<String>> messagesById) {
            super(messagesById.entrySet().stream()
                              .map(messages -> messages.getKey() + ":\n\t" +
                                               String.join("\n\t", messages.getValue()) + "\n" +
                                               toAllowMessage(messages.getKey()))
                              .collect(Collectors.joining("\n")));
        }

    }

}