diff options
17 files changed, 721 insertions, 371 deletions
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/CloudTenant.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/CloudTenant.java index 9894de86116..9a4e04ebb3a 100644 --- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/CloudTenant.java +++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/CloudTenant.java @@ -53,7 +53,7 @@ public class CloudTenant extends Tenant { createdAt, LastLoginInfo.EMPTY, Optional.ofNullable(creator), - ImmutableBiMap.of(), TenantInfo.EMPTY, List.of(), Optional.empty()); + ImmutableBiMap.of(), TenantInfo.empty(), List.of(), Optional.empty()); } /** The user that created the tenant */ diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/TenantAddress.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/TenantAddress.java new file mode 100644 index 00000000000..57c4757bde0 --- /dev/null +++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/TenantAddress.java @@ -0,0 +1,98 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.controller.tenant; + +import java.util.Objects; + +/** + * A generic address container that tries to make as few assumptions about addresses as possible. + * Most addresses have some of these fields, but with different names (e.g. postal code vs zip code). + * + * When consuming data from this class, do not make any assumptions about which fields have content. + * An address might be still valid with surprisingly little information. + * + * All fields are non-null, but might be empty strings. + * + * @author ogronnesby + */ +public class TenantAddress { + private final String address; + private final String code; + private final String city; + private final String region; + private final String country; + + TenantAddress(String address, String code, String city, String region, String country) { + this.address = Objects.requireNonNull(address, "'address' was null"); + this.code = Objects.requireNonNull(code, "'code' was null"); + this.city = Objects.requireNonNull(city, "'city' was null"); + this.region = Objects.requireNonNull(region, "'region' was null"); + this.country = Objects.requireNonNull(country, "'country' was null"); + } + + public static TenantAddress empty() { + return new TenantAddress("", "", "", "", ""); + } + + /** Multi-line fields that has the contents of the street address (or similar) */ + public String address() { return address; } + + /** The ZIP or postal code part of the address */ + public String code() { return code; } + + /** The city of the address */ + public String city() { return city; } + + /** The region part of the address - e.g. a state, county, or province */ + public String region() { return region; } + + /** The country part of the address. Its name, not a code */ + public String country() { return country; } + + public boolean isEmpty() { + return this.equals(empty()); + } + + public TenantAddress withAddress(String address) { + return new TenantAddress(address, code, city, region, country); + } + + public TenantAddress withCode(String code) { + return new TenantAddress(address, code, city, region, country); + } + + public TenantAddress withCity(String city) { + return new TenantAddress(address, code, city, region, country); + } + + public TenantAddress withRegion(String region) { + return new TenantAddress(address, code, city, region, country); + } + + public TenantAddress withCountry(String country) { + return new TenantAddress(address, code, city, region, country); + } + + @Override + public String toString() { + return "TenantAddress{" + + "address='" + address + '\'' + + ", code='" + code + '\'' + + ", city='" + city + '\'' + + ", region='" + region + '\'' + + ", country='" + country + '\'' + + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + TenantAddress that = (TenantAddress) o; + return Objects.equals(address, that.address) && Objects.equals(code, that.code) && Objects.equals(city, that.city) && Objects.equals(region, that.region) && Objects.equals(country, that.country); + } + + @Override + public int hashCode() { + return Objects.hash(address, code, city, region, country); + } +} diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/TenantBilling.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/TenantBilling.java new file mode 100644 index 00000000000..61381f308ef --- /dev/null +++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/TenantBilling.java @@ -0,0 +1,63 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.controller.tenant; + +import java.util.Objects; + +/** + * @author smorgrav + */ +public class TenantBilling { + + private final TenantContact contact; + private final TenantAddress address; + + public TenantBilling(TenantContact contact, TenantAddress address) { + this.contact = Objects.requireNonNull(contact); + this.address = Objects.requireNonNull(address); + } + + public static TenantBilling empty() { + return new TenantBilling(TenantContact.empty(), TenantAddress.empty()); + } + + public TenantContact contact() { + return contact; + } + + public TenantAddress address() { + return address; + } + + public TenantBilling withContact(TenantContact updatedContact) { + return new TenantBilling(updatedContact, this.address); + } + + public TenantBilling withAddress(TenantAddress updatedAddress) { + return new TenantBilling(this.contact, updatedAddress); + } + + public boolean isEmpty() { + return this.equals(empty()); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + TenantBilling that = (TenantBilling) o; + return Objects.equals(contact, that.contact) && Objects.equals(address, that.address); + } + + @Override + public int hashCode() { + return Objects.hash(contact, address); + } + + @Override + public String toString() { + return "TenantInfoBillingContact{" + + "contact=" + contact + + ", address=" + address + + '}'; + } +} diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/TenantContact.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/TenantContact.java new file mode 100644 index 00000000000..3aa5600ed87 --- /dev/null +++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/TenantContact.java @@ -0,0 +1,69 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.controller.tenant; + +import java.util.Objects; + +/** + * @author ogronnesby + */ +public class TenantContact { + private final String name; + private final String email; + private final String phone; + + private TenantContact(String name, String email, String phone) { + this.name = Objects.requireNonNull(name); + this.email = Objects.requireNonNull(email); + this.phone = Objects.requireNonNull(phone); + } + + public static TenantContact from(String name, String email, String phone) { + return new TenantContact(name, email, phone); + } + + public static TenantContact from(String name, String email) { + return TenantContact.from(name, email, ""); + } + + public static TenantContact empty() { + return new TenantContact("", "", ""); + } + + public String name() { return name; } + public String email() { return email; } + public String phone() { return phone; } + + public TenantContact withName(String name) { + return new TenantContact(name, email, phone); + } + + public TenantContact withEmail(String email) { + return new TenantContact(name, email, phone); + } + + public TenantContact withPhone(String phone) { + return new TenantContact(name, email, phone); + } + + @Override + public String toString() { + return "TenantContact{" + + "name='" + name + '\'' + + ", email='" + email + '\'' + + ", phone='" + phone + '\'' + + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + TenantContact that = (TenantContact) o; + return Objects.equals(name, that.name) && Objects.equals(email, that.email) && Objects.equals(phone, that.phone); + } + + @Override + public int hashCode() { + return Objects.hash(name, email, phone); + } +} diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/TenantContacts.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/TenantContacts.java new file mode 100644 index 00000000000..14635a3bb30 --- /dev/null +++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/TenantContacts.java @@ -0,0 +1,147 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.controller.tenant; + +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** + * Tenant contacts are targets of the notification system. Sometimes they + * are a person with an email address, other times they are a Slack channel, + * IRC plugin, etc. + * + * @author ogronnesby + */ +public class TenantContacts { + private final List<? extends Contact> contacts; + + public TenantContacts(List<? extends Contact> contacts) { + this.contacts = List.copyOf(contacts); + } + + public static TenantContacts empty() { + return new TenantContacts(List.of()); + } + + public List<? extends Contact> all() { + return contacts; + } + + public boolean isEmpty() { + return contacts.isEmpty(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + TenantContacts that = (TenantContacts) o; + return contacts.equals(that.contacts); + } + + @Override + public int hashCode() { + return Objects.hash(contacts); + } + + @Override + public String toString() { + return "TenantContacts{" + + "contacts=" + contacts + + '}'; + } + + public abstract static class Contact { + private final List<Audience> audiences; + + public Contact(List<Audience> audiences) { + this.audiences = List.copyOf(audiences); + if (audiences.isEmpty()) throw new IllegalArgumentException("at least one notification activity must be enabled"); + } + + public List<Audience> audiences() { return audiences; } + + public abstract Type type(); + + public abstract boolean equals(Object o); + public abstract int hashCode(); + public abstract String toString(); + } + + public static class EmailContact extends Contact { + private final String email; + + public EmailContact(List<Audience> audiences, String email) { + super(audiences); + this.email = email; + } + + public String email() { return email; } + + @Override + public Type type() { + return Type.EMAIL; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + EmailContact that = (EmailContact) o; + return email.equals(that.email); + } + + @Override + public int hashCode() { + return Objects.hash(email); + } + + @Override + public String toString() { + return "EmailContact{" + + "email='" + email + '\'' + + '}'; + } + } + + public enum Type { + EMAIL("email"); + + private final String value; + + Type(String value) { + this.value = value; + } + + public String value() { + return this.value; + } + + public static Optional<Type> from(String value) { + return Arrays.stream(Type.values()).filter(x -> x.value().equals(value)).findAny(); + } + } + + public enum Audience { + // tenant admin type updates about billing etc. + TENANT("tenant"), + + // system notifications like deployment failures etc. + NOTIFICATIONS("notifications"); + + private final String value; + + Audience(String value) { + this.value = value; + } + + public String value() { + return value; + } + + public static Optional<Audience> from(String value) { + return Arrays.stream(Audience.values()).filter((x -> x.value().equals(value))).findAny(); + } + } +} diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/TenantInfo.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/TenantInfo.java index 87d4f03b090..0ca7863fbce 100644 --- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/TenantInfo.java +++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/TenantInfo.java @@ -13,51 +13,34 @@ import java.util.Objects; * @author smorgrav */ public class TenantInfo { - // Editable as 'Tenant Information - Company Name' - // Viewable in the 'Account - Profile' section as 'Company Name' - private final String name; - // Editable as 'Tenant Information - Email' - // Not displayed outside of 'Edit profile' + private final String name; private final String email; - - // Editable as 'Tenant Information - Website' - // Viewable in the 'Account - Profile' section at bottom of 'Contact Information' private final String website; - // Editable as 'Contact Information - Contact Name' - // Viewable in the 'Account - Profile' section in 'Contact Information' - private final String contactName; - - // Editable as 'Contact Information - Contact Email' - // Viewable in the 'Account - Profile' section in 'Contact Information' - private final String contactEmail; - - // Not editable in the account setting - // Not viewable. - // TODO: Remove - private final String invoiceEmail; - - // See class for more info - private final TenantInfoAddress address; - - // See class for more info - private final TenantInfoBillingContact billingContact; + private final TenantContact contact; + private final TenantAddress address; + private final TenantBilling billingContact; + private final TenantContacts contacts; TenantInfo(String name, String email, String website, String contactName, String contactEmail, - String invoiceEmail, TenantInfoAddress address, TenantInfoBillingContact billingContact) { + TenantAddress address, TenantBilling billingContact, TenantContacts contacts) { + this(name, email, website, TenantContact.from(contactName, contactEmail), address, billingContact, contacts); + } + + TenantInfo(String name, String email, String website, TenantContact contact, TenantAddress address, TenantBilling billing, TenantContacts contacts) { this.name = Objects.requireNonNull(name); this.email = Objects.requireNonNull(email); this.website = Objects.requireNonNull(website); - this.contactName = Objects.requireNonNull(contactName); - this.contactEmail = Objects.requireNonNull(contactEmail); - this.invoiceEmail = Objects.requireNonNull(invoiceEmail); + this.contact = Objects.requireNonNull(contact); this.address = Objects.requireNonNull(address); - this.billingContact = Objects.requireNonNull(billingContact); + this.billingContact = Objects.requireNonNull(billing); + this.contacts = Objects.requireNonNull(contacts); } - public static final TenantInfo EMPTY = new TenantInfo("","","", "", "", "", - TenantInfoAddress.EMPTY, TenantInfoBillingContact.EMPTY); + public static TenantInfo empty() { + return new TenantInfo("", "", "", "", "", TenantAddress.empty(), TenantBilling.empty(), TenantContacts.empty()); + } public String name() { return name; @@ -71,60 +54,46 @@ public class TenantInfo { return website; } - public String contactName() { - return contactName; - } - - public String contactEmail() { - return contactEmail; - } - - public String invoiceEmail() { - return invoiceEmail; - } + public TenantContact contact() { return contact; } - public TenantInfoAddress address() { - return address; - } + public TenantAddress address() { return address; } - public TenantInfoBillingContact billingContact() { + public TenantBilling billingContact() { return billingContact; } - public TenantInfo withName(String newName) { - return new TenantInfo(newName, email, website, contactName, contactEmail, invoiceEmail, address, billingContact); - } + public TenantContacts contacts() { return contacts; } - public TenantInfo withEmail(String newEmail) { - return new TenantInfo(name, newEmail, website, contactName, contactEmail, invoiceEmail, address, billingContact); + public boolean isEmpty() { + return this.equals(empty()); } - public TenantInfo withWebsite(String newWebsite) { - return new TenantInfo(name, email, newWebsite, contactName, contactEmail, invoiceEmail, address, billingContact); + public TenantInfo withName(String name) { + return new TenantInfo(name, email, website, contact, address, billingContact, contacts); } - public TenantInfo withContactName(String newContactName) { - return new TenantInfo(name, email, website, newContactName, contactEmail, invoiceEmail, address, billingContact); + public TenantInfo withEmail(String email) { + return new TenantInfo(name, email, website, contact, address, billingContact, contacts); } - public TenantInfo withContactEmail(String newContactEmail) { - return new TenantInfo(name, email, website, contactName, newContactEmail, invoiceEmail, address, billingContact); + public TenantInfo withWebsite(String website) { + return new TenantInfo(name, email, website, contact, address, billingContact, contacts); } - public TenantInfo withInvoiceEmail(String newInvoiceEmail) { - return new TenantInfo(name, email, website, contactName, contactEmail, newInvoiceEmail, address, billingContact); + public TenantInfo withContact(TenantContact contact) { + return new TenantInfo(name, email, website, contact, address, billingContact, contacts); } - public TenantInfo withAddress(TenantInfoAddress newAddress) { - return new TenantInfo(name, email, website, contactName, contactEmail, invoiceEmail, newAddress, billingContact); + public TenantInfo withAddress(TenantAddress address) { + return new TenantInfo(name, email, website, contact, address, billingContact, contacts); } - public TenantInfo withBillingContact(TenantInfoBillingContact newBillingContact) { - return new TenantInfo(name, email, website, contactName, contactEmail, invoiceEmail, address, newBillingContact); + public TenantInfo withBilling(TenantBilling billingContact) { + return new TenantInfo(name, email, website, contact, address, billingContact, contacts); } - public boolean isEmpty() { - return this.equals(EMPTY); + public TenantInfo withContacts(TenantContacts contacts) { + return new TenantInfo(name, email, website, contact, address, billingContact, contacts); } @Override @@ -132,18 +101,30 @@ public class TenantInfo { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; TenantInfo that = (TenantInfo) o; - return name.equals(that.name) && - email.equals(that.email) && - website.equals(that.website) && - contactName.equals(that.contactName) && - contactEmail.equals(that.contactEmail) && - invoiceEmail.equals(that.invoiceEmail) && - address.equals(that.address) && - billingContact.equals(that.billingContact); + return Objects.equals(name, that.name) && + Objects.equals(email, that.email) && + Objects.equals(website, that.website) && + Objects.equals(contact, that.contact) && + Objects.equals(address, that.address) && + Objects.equals(billingContact, that.billingContact) && + Objects.equals(contacts, that.contacts); } @Override public int hashCode() { - return Objects.hash(name, email, website, contactName, contactEmail, invoiceEmail, address, billingContact); + return Objects.hash(name, email, website, contact, address, billingContact, contacts); + } + + @Override + public String toString() { + return "TenantInfo{" + + "name='" + name + '\'' + + ", email='" + email + '\'' + + ", website='" + website + '\'' + + ", contact=" + contact + + ", address=" + address + + ", billingContact=" + billingContact + + ", contacts=" + contacts + + '}'; } } diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/TenantInfoAddress.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/TenantInfoAddress.java deleted file mode 100644 index 740adde4519..00000000000 --- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/TenantInfoAddress.java +++ /dev/null @@ -1,97 +0,0 @@ -// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. -package com.yahoo.vespa.hosted.controller.tenant; - -import java.util.Objects; - -/** - * Address formats are quite diverse across the world both in therms of what fields are used, named and - * the order of them. - * - * To be generic a little future proof the address fields here are a mix of free text (address lines) and fixed fields. - * The address lines can be street address, P.O box, c/o name, apartment, suite, unit, building floor etc etc. - * - * All fields are mandatory but can be an empty string (ie. not null) - * - * @author smorgrav - */ -public class TenantInfoAddress { - - // All fields are editable in 'Edit Profile - Company Address'. - // The fields are not exposed outside the 'Edit Profile' form. - private final String addressLines; - private final String postalCodeOrZip; - private final String city; - private final String stateRegionProvince; - private final String country; - - TenantInfoAddress(String addressLines, String postalCodeOrZip, String city, String country, String stateRegionProvince) { - this.addressLines = Objects.requireNonNull(addressLines);; - this.city = Objects.requireNonNull(city); - this.postalCodeOrZip = Objects.requireNonNull(postalCodeOrZip); - this.country = Objects.requireNonNull(country); - this.stateRegionProvince = Objects.requireNonNull(stateRegionProvince); - } - - public static final TenantInfoAddress EMPTY = new TenantInfoAddress("","","", "", ""); - - public String addressLines() { - return addressLines; - } - - public String postalCodeOrZip() { - return postalCodeOrZip; - } - - public String city() { - return city; - } - - public String country() { - return country; - } - - public String stateRegionProvince() { - return stateRegionProvince; - } - - public TenantInfoAddress withAddressLines(String newAddressLines) { - return new TenantInfoAddress(newAddressLines, postalCodeOrZip, city, country, stateRegionProvince); - } - - public TenantInfoAddress withPostalCodeOrZip(String newPostalCodeOrZip) { - return new TenantInfoAddress(addressLines, newPostalCodeOrZip, city, country, stateRegionProvince); - } - - public TenantInfoAddress withCity(String newCity) { - return new TenantInfoAddress(addressLines, postalCodeOrZip, newCity, country, stateRegionProvince); - } - - public TenantInfoAddress withCountry(String newCountry) { - return new TenantInfoAddress(addressLines, postalCodeOrZip, city, newCountry, stateRegionProvince); - } - - public TenantInfoAddress withStateRegionProvince(String newStateRegionProvince) { - return new TenantInfoAddress(addressLines, postalCodeOrZip, city, country, newStateRegionProvince); - } - - public boolean isEmpty() { - return this.equals(EMPTY); - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - TenantInfoAddress that = (TenantInfoAddress) o; - return addressLines.equals(that.addressLines) && - postalCodeOrZip.equals(that.postalCodeOrZip) && - city.equals(that.city) && - stateRegionProvince.equals(that.stateRegionProvince) && - country.equals(that.country); - } - - @Override - public int hashCode() { - return Objects.hash(addressLines, postalCodeOrZip, city, stateRegionProvince, country); - } -} diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/TenantInfoBillingContact.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/TenantInfoBillingContact.java deleted file mode 100644 index c875a19d57b..00000000000 --- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/TenantInfoBillingContact.java +++ /dev/null @@ -1,78 +0,0 @@ -// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. -package com.yahoo.vespa.hosted.controller.tenant; - -import java.util.Objects; - -/** - * @author smorgrav - */ -public class TenantInfoBillingContact { - - // All fields are editable in 'Billing - Edit billing contact' - // Only 'name' and 'email' are exposed outside the 'Edit billing contact' form. - // All these fields are required by the billing process. - private final String name; - private final String email; - private final String phone; - private final TenantInfoAddress address; - - TenantInfoBillingContact(String name, String email, String phone, TenantInfoAddress address) { - this.name = Objects.requireNonNull(name); - this.email = Objects.requireNonNull(email); - this.phone = Objects.requireNonNull(phone); - this.address = Objects.requireNonNull(address); - } - - public static final TenantInfoBillingContact EMPTY = - new TenantInfoBillingContact("","", "", TenantInfoAddress.EMPTY); - - public String name() { - return name; - } - - public String email() { return email; } - - public String phone() { - return phone; - } - - public TenantInfoAddress address() { - return address; - } - - public TenantInfoBillingContact withName(String newName) { - return new TenantInfoBillingContact(newName, email, phone, address); - } - - public TenantInfoBillingContact withEmail(String newEmail) { - return new TenantInfoBillingContact(name, newEmail, phone, address); - } - - public TenantInfoBillingContact withPhone(String newPhone) { - return new TenantInfoBillingContact(name, email, newPhone, address); - } - - public TenantInfoBillingContact withAddress(TenantInfoAddress newAddress) { - return new TenantInfoBillingContact(name, email, phone, newAddress); - } - - public boolean isEmpty() { - return this.equals(EMPTY); - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - TenantInfoBillingContact that = (TenantInfoBillingContact) o; - return name.equals(that.name) && - email.equals(that.email) && - phone.equals(that.phone) && - address.equals(that.address); - } - - @Override - public int hashCode() { - return Objects.hash(name, email, phone, address); - } -} diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/TenantSerializer.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/TenantSerializer.java index 1b6a0a6a122..00e38abcba7 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/TenantSerializer.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/TenantSerializer.java @@ -9,22 +9,24 @@ import com.yahoo.slime.ArrayTraverser; import com.yahoo.slime.Cursor; import com.yahoo.slime.Inspector; import com.yahoo.slime.Slime; -import com.yahoo.vespa.athenz.api.AthenzDomain; import com.yahoo.slime.SlimeUtils; +import com.yahoo.vespa.athenz.api.AthenzDomain; import com.yahoo.vespa.hosted.controller.api.identifiers.Property; import com.yahoo.vespa.hosted.controller.api.identifiers.PropertyId; +import com.yahoo.vespa.hosted.controller.api.integration.organization.BillingInfo; +import com.yahoo.vespa.hosted.controller.api.integration.organization.Contact; import com.yahoo.vespa.hosted.controller.api.integration.secrets.TenantSecretStore; import com.yahoo.vespa.hosted.controller.api.role.SimplePrincipal; -import com.yahoo.vespa.hosted.controller.api.integration.organization.Contact; -import com.yahoo.vespa.hosted.controller.api.integration.organization.BillingInfo; import com.yahoo.vespa.hosted.controller.tenant.AthenzTenant; import com.yahoo.vespa.hosted.controller.tenant.CloudTenant; import com.yahoo.vespa.hosted.controller.tenant.DeletedTenant; import com.yahoo.vespa.hosted.controller.tenant.LastLoginInfo; import com.yahoo.vespa.hosted.controller.tenant.Tenant; +import com.yahoo.vespa.hosted.controller.tenant.TenantAddress; +import com.yahoo.vespa.hosted.controller.tenant.TenantBilling; +import com.yahoo.vespa.hosted.controller.tenant.TenantContact; +import com.yahoo.vespa.hosted.controller.tenant.TenantContacts; import com.yahoo.vespa.hosted.controller.tenant.TenantInfo; -import com.yahoo.vespa.hosted.controller.tenant.TenantInfoAddress; -import com.yahoo.vespa.hosted.controller.tenant.TenantInfoBillingContact; import java.net.URI; import java.security.Principal; @@ -35,6 +37,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.stream.Collectors; /** * Slime serialization of {@link Tenant} sub-types. @@ -193,50 +196,47 @@ public class TenantSerializer { } TenantInfo tenantInfoFromSlime(Inspector infoObject) { - if (!infoObject.valid()) return TenantInfo.EMPTY; + if (!infoObject.valid()) return TenantInfo.empty(); - return TenantInfo.EMPTY + return TenantInfo.empty() .withName(infoObject.field("name").asString()) .withEmail(infoObject.field("email").asString()) .withWebsite(infoObject.field("website").asString()) - .withContactName(infoObject.field("contactName").asString()) - .withContactEmail(infoObject.field("contactEmail").asString()) - .withInvoiceEmail(infoObject.field("invoiceEmail").asString()) + .withContact(TenantContact.from( + infoObject.field("contactName").asString(), + infoObject.field("contactEmail").asString())) .withAddress(tenantInfoAddressFromSlime(infoObject.field("address"))) - .withBillingContact(tenantInfoBillingContactFromSlime(infoObject.field("billingContact"))); + .withBilling(tenantInfoBillingContactFromSlime(infoObject.field("billingContact"))) + .withContacts(tenantContactsFrom(infoObject.field("contacts"))); } - private TenantInfoAddress tenantInfoAddressFromSlime(Inspector addressObject) { - return TenantInfoAddress.EMPTY - .withAddressLines(addressObject.field("addressLines").asString()) - .withPostalCodeOrZip(addressObject.field("postalCodeOrZip").asString()) + private TenantAddress tenantInfoAddressFromSlime(Inspector addressObject) { + return TenantAddress.empty() + .withAddress(addressObject.field("addressLines").asString()) + .withCode(addressObject.field("postalCodeOrZip").asString()) .withCity(addressObject.field("city").asString()) - .withStateRegionProvince(addressObject.field("stateRegionProvince").asString()) + .withRegion(addressObject.field("stateRegionProvince").asString()) .withCountry(addressObject.field("country").asString()); } - private TenantInfoBillingContact tenantInfoBillingContactFromSlime(Inspector billingObject) { - return TenantInfoBillingContact.EMPTY - .withName(billingObject.field("name").asString()) - .withEmail(billingObject.field("email").asString()) - .withPhone(billingObject.field("phone").asString()) + private TenantBilling tenantInfoBillingContactFromSlime(Inspector billingObject) { + return TenantBilling.empty() + .withContact(TenantContact.from( + billingObject.field("name").asString(), + billingObject.field("email").asString(), + billingObject.field("phone").asString())) .withAddress(tenantInfoAddressFromSlime(billingObject.field("address"))); } private List<TenantSecretStore> secretStoresFromSlime(Inspector secretStoresObject) { - List<TenantSecretStore> secretStores = new ArrayList<>(); - if (!secretStoresObject.valid()) return secretStores; - - secretStoresObject.traverse((ArrayTraverser) (index, inspector) -> { - secretStores.add( - new TenantSecretStore( - inspector.field(nameField).asString(), - inspector.field(awsIdField).asString(), - inspector.field(roleField).asString() - ) - ); - }); - return secretStores; + if (!secretStoresObject.valid()) return List.of(); + + return SlimeUtils.entriesStream(secretStoresObject) + .map(inspector -> new TenantSecretStore( + inspector.field(nameField).asString(), + inspector.field(awsIdField).asString(), + inspector.field(roleField).asString())) + .collect(Collectors.toUnmodifiableList()); } private LastLoginInfo lastLoginInfoFromSlime(Inspector lastLoginInfoObject) { @@ -252,31 +252,31 @@ public class TenantSerializer { infoCursor.setString("name", info.name()); infoCursor.setString("email", info.email()); infoCursor.setString("website", info.website()); - infoCursor.setString("invoiceEmail", info.invoiceEmail()); - infoCursor.setString("contactName", info.contactName()); - infoCursor.setString("contactEmail", info.contactEmail()); + infoCursor.setString("contactName", info.contact().name()); + infoCursor.setString("contactEmail", info.contact().email()); toSlime(info.address(), infoCursor); toSlime(info.billingContact(), infoCursor); + toSlime(info.contacts(), infoCursor); } - private void toSlime(TenantInfoAddress address, Cursor parentCursor) { + private void toSlime(TenantAddress address, Cursor parentCursor) { if (address.isEmpty()) return; Cursor addressCursor = parentCursor.setObject("address"); - addressCursor.setString("addressLines", address.addressLines()); - addressCursor.setString("postalCodeOrZip", address.postalCodeOrZip()); + addressCursor.setString("addressLines", address.address()); + addressCursor.setString("postalCodeOrZip", address.code()); addressCursor.setString("city", address.city()); - addressCursor.setString("stateRegionProvince", address.stateRegionProvince()); + addressCursor.setString("stateRegionProvince", address.region()); addressCursor.setString("country", address.country()); } - private void toSlime(TenantInfoBillingContact billingContact, Cursor parentCursor) { + private void toSlime(TenantBilling billingContact, Cursor parentCursor) { if (billingContact.isEmpty()) return; Cursor addressCursor = parentCursor.setObject("billingContact"); - addressCursor.setString("name", billingContact.name()); - addressCursor.setString("email", billingContact.email()); - addressCursor.setString("phone", billingContact.phone()); + addressCursor.setString("name", billingContact.contact().name()); + addressCursor.setString("email", billingContact.contact().email()); + addressCursor.setString("phone", billingContact.contact().phone()); toSlime(billingContact.address(), addressCursor); } @@ -290,7 +290,19 @@ public class TenantSerializer { secretStoreCursor.setString(awsIdField, tenantSecretStore.getAwsId()); secretStoreCursor.setString(roleField, tenantSecretStore.getRole()); }); + } + + private void toSlime(TenantContacts contacts, Cursor parent) { + if (contacts.isEmpty()) return; + var cursor = parent.setArray("contacts"); + contacts.all().forEach(contact -> writeContact(contact, cursor.addObject())); + } + private TenantContacts tenantContactsFrom(Inspector object) { + List<TenantContacts.Contact> contacts = SlimeUtils.entriesStream(object) + .map(this::readContact) + .collect(Collectors.toUnmodifiableList()); + return new TenantContacts(contacts); } private Optional<Contact> contactFrom(Inspector object) { @@ -336,6 +348,36 @@ public class TenantSerializer { return personLists; } + private void writeContact(TenantContacts.Contact contact, Cursor cursor) { + cursor.setString("type", contact.type().value()); + Cursor audiencesArray = cursor.setArray("audiences"); + contact.audiences().forEach(audience -> audiencesArray.addString(toAudience(audience))); + var data = cursor.setObject("data"); + switch (contact.type()) { + case EMAIL: + var email = (TenantContacts.EmailContact) contact; + data.setString("email", email.email()); + return; + default: + throw new IllegalArgumentException("Serialization for contact type not implemented: " + contact.type()); + } + } + + private TenantContacts.Contact readContact(Inspector inspector) { + var type = TenantContacts.Type.from(inspector.field("type").asString()) + .orElseThrow(() -> new RuntimeException("Unknown type: " + inspector.field("type").asString())); + var audiences = SlimeUtils.entriesStream(inspector.field("audiences")) + .map(audience -> TenantSerializer.fromAudience(audience.asString())) + .collect(Collectors.toUnmodifiableList()); + switch (type) { + case EMAIL: + return new TenantContacts.EmailContact(audiences, inspector.field("data").field("email").asString()); + default: + throw new IllegalArgumentException("Serialization for contact type not implemented: " + type); + } + + } + private static Tenant.Type typeOf(String value) { switch (value) { case "athenz": return Tenant.Type.athenz; @@ -371,4 +413,21 @@ public class TenantSerializer { default: throw new IllegalArgumentException("Unexpected user level '" + userLevel + "'."); } } + + private static TenantContacts.Audience fromAudience(String value) { + switch (value) { + case "tenant": return TenantContacts.Audience.TENANT; + case "notifications": return TenantContacts.Audience.NOTIFICATIONS; + default: throw new IllegalArgumentException("Unknown contact audience '" + value + "'."); + } + } + + private static String toAudience(TenantContacts.Audience audience) { + switch (audience) { + case TENANT: return "tenant"; + case NOTIFICATIONS: return "notifications"; + default: throw new IllegalArgumentException("Unexpected contact audience '" + audience + "'."); + } + } + } diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiHandler.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiHandler.java index c8e16634464..6490ade655a 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiHandler.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiHandler.java @@ -111,9 +111,11 @@ import com.yahoo.vespa.hosted.controller.tenant.CloudTenant; import com.yahoo.vespa.hosted.controller.tenant.DeletedTenant; import com.yahoo.vespa.hosted.controller.tenant.LastLoginInfo; import com.yahoo.vespa.hosted.controller.tenant.Tenant; +import com.yahoo.vespa.hosted.controller.tenant.TenantAddress; +import com.yahoo.vespa.hosted.controller.tenant.TenantBilling; +import com.yahoo.vespa.hosted.controller.tenant.TenantContact; +import com.yahoo.vespa.hosted.controller.tenant.TenantContacts; import com.yahoo.vespa.hosted.controller.tenant.TenantInfo; -import com.yahoo.vespa.hosted.controller.tenant.TenantInfoAddress; -import com.yahoo.vespa.hosted.controller.tenant.TenantInfoBillingContact; import com.yahoo.vespa.hosted.controller.versions.VersionStatus; import com.yahoo.vespa.hosted.controller.versions.VespaVersion; import com.yahoo.vespa.serviceview.bindings.ApplicationView; @@ -504,37 +506,71 @@ public class ApplicationApiHandler extends AuditLoggingRequestHandler { infoCursor.setString("name", info.name()); infoCursor.setString("email", info.email()); infoCursor.setString("website", info.website()); - infoCursor.setString("invoiceEmail", info.invoiceEmail()); - infoCursor.setString("contactName", info.contactName()); - infoCursor.setString("contactEmail", info.contactEmail()); + infoCursor.setString("contactName", info.contact().name()); + infoCursor.setString("contactEmail", info.contact().email()); toSlime(info.address(), infoCursor); toSlime(info.billingContact(), infoCursor); + toSlime(info.contacts(), infoCursor); } return new SlimeJsonResponse(slime); } - private void toSlime(TenantInfoAddress address, Cursor parentCursor) { + private void toSlime(TenantAddress address, Cursor parentCursor) { if (address.isEmpty()) return; Cursor addressCursor = parentCursor.setObject("address"); - addressCursor.setString("addressLines", address.addressLines()); - addressCursor.setString("postalCodeOrZip", address.postalCodeOrZip()); + addressCursor.setString("addressLines", address.address()); + addressCursor.setString("postalCodeOrZip", address.code()); addressCursor.setString("city", address.city()); - addressCursor.setString("stateRegionProvince", address.stateRegionProvince()); + addressCursor.setString("stateRegionProvince", address.region()); addressCursor.setString("country", address.country()); } - private void toSlime(TenantInfoBillingContact billingContact, Cursor parentCursor) { + private void toSlime(TenantBilling billingContact, Cursor parentCursor) { if (billingContact.isEmpty()) return; Cursor addressCursor = parentCursor.setObject("billingContact"); - addressCursor.setString("name", billingContact.name()); - addressCursor.setString("email", billingContact.email()); - addressCursor.setString("phone", billingContact.phone()); + addressCursor.setString("name", billingContact.contact().name()); + addressCursor.setString("email", billingContact.contact().email()); + addressCursor.setString("phone", billingContact.contact().phone()); toSlime(billingContact.address(), addressCursor); } + private void toSlime(TenantContacts contacts, Cursor parentCursor) { + Cursor contactsCursor = parentCursor.setArray("contacts"); + contacts.all().forEach(contact -> { + Cursor contactCursor = contactsCursor.addObject(); + Cursor audiencesArray = contactCursor.setArray("audiences"); + contact.audiences().forEach(audience -> audiencesArray.addString(toAudience(audience))); + switch (contact.type()) { + case EMAIL: + var email = (TenantContacts.EmailContact) contact; + contactCursor.setString("email", email.email()); + return; + default: + throw new IllegalArgumentException("Serialization for contact type not implemented: " + contact.type()); + } + }); + } + + private static TenantContacts.Audience fromAudience(String value) { + switch (value) { + case "tenant": return TenantContacts.Audience.TENANT; + case "notifications": return TenantContacts.Audience.NOTIFICATIONS; + default: throw new IllegalArgumentException("Unknown contact audience '" + value + "'."); + } + } + + private static String toAudience(TenantContacts.Audience audience) { + switch (audience) { + case TENANT: return "tenant"; + case NOTIFICATIONS: return "notifications"; + default: throw new IllegalArgumentException("Unexpected contact audience '" + audience + "'."); + } + } + + private HttpResponse updateTenantInfo(String tenantName, HttpRequest request) { return controller.tenants().get(TenantName.from(tenantName)) .filter(tenant -> tenant.type() == Tenant.Type.cloud) @@ -551,24 +587,28 @@ public class ApplicationApiHandler extends AuditLoggingRequestHandler { // Merge info from request with the existing info Inspector insp = toSlime(request.getData()).get(); - TenantInfo mergedInfo = TenantInfo.EMPTY + + TenantContact mergedContact = TenantContact.empty() + .withName(getString(insp.field("contactName"), oldInfo.contact().name())) + .withEmail(getString(insp.field("contactEmail"), oldInfo.contact().email())); + + TenantInfo mergedInfo = TenantInfo.empty() .withName(getString(insp.field("name"), oldInfo.name())) - .withEmail(getString(insp.field("email"), oldInfo.email())) - .withWebsite(getString(insp.field("website"), oldInfo.website())) - .withInvoiceEmail(getString(insp.field("invoiceEmail"), oldInfo.invoiceEmail())) - .withContactName(getString(insp.field("contactName"), oldInfo.contactName())) - .withContactEmail(getString(insp.field("contactEmail"), oldInfo.contactEmail())) + .withEmail(getString(insp.field("email"), oldInfo.email())) + .withWebsite(getString(insp.field("website"), oldInfo.website())) + .withContact(mergedContact) .withAddress(updateTenantInfoAddress(insp.field("address"), oldInfo.address())) - .withBillingContact(updateTenantInfoBillingContact(insp.field("billingContact"), oldInfo.billingContact())); + .withBilling(updateTenantInfoBillingContact(insp.field("billingContact"), oldInfo.billingContact())) + .withContacts(updateTenantInfoContacts(insp.field("contacts"), oldInfo.contacts())); // Assert that we have a valid tenant info - if (mergedInfo.contactName().isBlank()) { + if (mergedInfo.contact().name().isBlank()) { throw new IllegalArgumentException("'contactName' cannot be empty"); } - if (mergedInfo.contactEmail().isBlank()) { + if (mergedInfo.contact().email().isBlank()) { throw new IllegalArgumentException("'contactEmail' cannot be empty"); } - if (! mergedInfo.contactEmail().contains("@")) { + if (! mergedInfo.contact().email().contains("@")) { // email address validation is notoriously hard - we should probably just try to send a // verification email to this address. checking for @ is a simple best-effort. throw new IllegalArgumentException("'contactEmail' needs to be an email address"); @@ -590,20 +630,20 @@ public class ApplicationApiHandler extends AuditLoggingRequestHandler { return new MessageResponse("Tenant info updated"); } - private TenantInfoAddress updateTenantInfoAddress(Inspector insp, TenantInfoAddress oldAddress) { + private TenantAddress updateTenantInfoAddress(Inspector insp, TenantAddress oldAddress) { if (!insp.valid()) return oldAddress; - TenantInfoAddress address = TenantInfoAddress.EMPTY + TenantAddress address = TenantAddress.empty() .withCountry(getString(insp.field("country"), oldAddress.country())) - .withStateRegionProvince(getString(insp.field("stateRegionProvince"), oldAddress.stateRegionProvince())) + .withRegion(getString(insp.field("stateRegionProvince"), oldAddress.region())) .withCity(getString(insp.field("city"), oldAddress.city())) - .withPostalCodeOrZip(getString(insp.field("postalCodeOrZip"), oldAddress.postalCodeOrZip())) - .withAddressLines(getString(insp.field("addressLines"), oldAddress.addressLines())); + .withCode(getString(insp.field("postalCodeOrZip"), oldAddress.code())) + .withAddress(getString(insp.field("addressLines"), oldAddress.address())); - List<String> fields = List.of(address.addressLines(), - address.postalCodeOrZip(), + List<String> fields = List.of(address.address(), + address.code(), address.country(), address.city(), - address.stateRegionProvince()); + address.region()); if (fields.stream().allMatch(String::isBlank) || fields.stream().noneMatch(String::isBlank)) return address; @@ -611,7 +651,7 @@ public class ApplicationApiHandler extends AuditLoggingRequestHandler { throw new IllegalArgumentException("All address fields must be set"); } - private TenantInfoBillingContact updateTenantInfoBillingContact(Inspector insp, TenantInfoBillingContact oldContact) { + private TenantContact updateTenantInfoContact(Inspector insp, TenantContact oldContact) { if (!insp.valid()) return oldContact; String email = getString(insp.field("email"), oldContact.email()); @@ -622,13 +662,37 @@ public class ApplicationApiHandler extends AuditLoggingRequestHandler { throw new IllegalArgumentException("'email' needs to be an email address"); } - return TenantInfoBillingContact.EMPTY + return TenantContact.empty() .withName(getString(insp.field("name"), oldContact.name())) - .withEmail(email) - .withPhone(getString(insp.field("phone"), oldContact.phone())) + .withEmail(getString(insp.field("email"), oldContact.email())) + .withPhone(getString(insp.field("phone"), oldContact.phone())); + } + + private TenantBilling updateTenantInfoBillingContact(Inspector insp, TenantBilling oldContact) { + if (!insp.valid()) return oldContact; + + return TenantBilling.empty() + .withContact(updateTenantInfoContact(insp, oldContact.contact())) .withAddress(updateTenantInfoAddress(insp.field("address"), oldContact.address())); } + private TenantContacts updateTenantInfoContacts(Inspector insp, TenantContacts oldContacts) { + if (!insp.valid()) return oldContacts; + + List<TenantContacts.Contact> contacts = SlimeUtils.entriesStream(insp).map(inspector -> { + String email = inspector.field("email").asString().trim(); + List<TenantContacts.Audience> audiences = SlimeUtils.entriesStream(inspector.field("audiences")) + .map(audience -> fromAudience(audience.asString())) + .collect(Collectors.toUnmodifiableList()); + if (!email.contains("@")) { + throw new IllegalArgumentException("'email' needs to be an email address"); + } + return new TenantContacts.EmailContact(audiences, email); + }).collect(toUnmodifiableList()); + + return new TenantContacts(contacts); + } + private HttpResponse notifications(HttpRequest request, Optional<String> tenant, boolean includeTenantFieldInResponse) { boolean productionOnly = showOnlyProductionInstances(request); boolean excludeMessages = "true".equals(request.getProperty("excludeMessages")); @@ -648,6 +712,7 @@ public class ApplicationApiHandler extends AuditLoggingRequestHandler { .forEach(notification -> toSlime(notificationsArray.addObject(), notification, includeTenantFieldInResponse, excludeMessages)); return new SlimeJsonResponse(slime); } + private static <T> boolean propertyEquals(HttpRequest request, String property, Function<String, T> mapper, Optional<T> value) { return Optional.ofNullable(request.getProperty(property)) .map(propertyValue -> value.isPresent() && mapper.apply(propertyValue).equals(value.get())) @@ -1852,8 +1917,7 @@ public class ApplicationApiHandler extends AuditLoggingRequestHandler { User user = getAttribute(request, User.ATTRIBUTE_NAME, User.class); TenantInfo info = controller.tenants().require(tenant, CloudTenant.class) .info() - .withContactName(user.name()) - .withContactEmail(user.email()); + .withContact(TenantContact.from(user.name(), user.email())); // Store changes controller.tenants().lockOrThrow(tenant, LockedTenant.Cloud.class, lockedTenant -> { lockedTenant = lockedTenant.withInfo(info); diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/persistence/TenantSerializerTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/persistence/TenantSerializerTest.java index 0b986667911..e0d14f19f21 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/persistence/TenantSerializerTest.java +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/persistence/TenantSerializerTest.java @@ -16,9 +16,11 @@ import com.yahoo.vespa.hosted.controller.tenant.AthenzTenant; import com.yahoo.vespa.hosted.controller.tenant.CloudTenant; import com.yahoo.vespa.hosted.controller.tenant.DeletedTenant; import com.yahoo.vespa.hosted.controller.tenant.LastLoginInfo; +import com.yahoo.vespa.hosted.controller.tenant.TenantAddress; +import com.yahoo.vespa.hosted.controller.tenant.TenantBilling; +import com.yahoo.vespa.hosted.controller.tenant.TenantContact; +import com.yahoo.vespa.hosted.controller.tenant.TenantContacts; import com.yahoo.vespa.hosted.controller.tenant.TenantInfo; -import com.yahoo.vespa.hosted.controller.tenant.TenantInfoAddress; -import com.yahoo.vespa.hosted.controller.tenant.TenantInfoBillingContact; import org.junit.Test; import java.net.URI; @@ -97,7 +99,7 @@ public class TenantSerializerTest { Optional.of(new SimplePrincipal("foobar-user")), ImmutableBiMap.of(publicKey, new SimplePrincipal("joe"), otherPublicKey, new SimplePrincipal("jane")), - TenantInfo.EMPTY, + TenantInfo.empty(), List.of(), Optional.empty() ); @@ -116,7 +118,7 @@ public class TenantSerializerTest { Optional.of(new SimplePrincipal("foobar-user")), ImmutableBiMap.of(publicKey, new SimplePrincipal("joe"), otherPublicKey, new SimplePrincipal("jane")), - TenantInfo.EMPTY.withName("Ofni Tnanet"), + TenantInfo.empty().withName("Ofni Tnanet"), List.of( new TenantSecretStore("ss1", "123", "role1"), new TenantSecretStore("ss2", "124", "role2") @@ -131,39 +133,35 @@ public class TenantSerializerTest { @Test public void cloud_tenant_with_tenant_info_partial() { - TenantInfo partialInfo = TenantInfo.EMPTY - .withAddress(TenantInfoAddress.EMPTY.withCity("Hønefoss")); + TenantInfo partialInfo = TenantInfo.empty() + .withAddress(TenantAddress.empty().withCity("Hønefoss")); Slime slime = new Slime(); Cursor parentObject = slime.setObject(); serializer.toSlime(partialInfo, parentObject); - assertEquals("{\"info\":{\"name\":\"\",\"email\":\"\",\"website\":\"\",\"invoiceEmail\":\"\",\"contactName\":\"\",\"contactEmail\":\"\",\"address\":{\"addressLines\":\"\",\"postalCodeOrZip\":\"\",\"city\":\"Hønefoss\",\"stateRegionProvince\":\"\",\"country\":\"\"}}}", slime.toString()); + assertEquals("{\"info\":{\"name\":\"\",\"email\":\"\",\"website\":\"\",\"contactName\":\"\",\"contactEmail\":\"\",\"address\":{\"addressLines\":\"\",\"postalCodeOrZip\":\"\",\"city\":\"Hønefoss\",\"stateRegionProvince\":\"\",\"country\":\"\"}}}", slime.toString()); } @Test public void cloud_tenant_with_tenant_info_full() { - TenantInfo fullInfo = TenantInfo.EMPTY + TenantInfo fullInfo = TenantInfo.empty() .withName("My Company") .withEmail("email@mycomp.any") .withWebsite("http://mycomp.any") - .withContactEmail("ceo@mycomp.any") - .withContactName("My Name") - .withInvoiceEmail("invoice@mycomp.any") - .withAddress(TenantInfoAddress.EMPTY + .withContact(TenantContact.from("My Name", "ceo@mycomp.any")) + .withAddress(TenantAddress.empty() .withCity("Hønefoss") - .withAddressLines("Riperbakken 2") + .withAddress("Riperbakken 2") .withCountry("Norway") - .withPostalCodeOrZip("3510") - .withStateRegionProvince("Viken")) - .withBillingContact(TenantInfoBillingContact.EMPTY - .withEmail("thomas@sodor.com") - .withName("Thomas The Tank Engine") - .withPhone("NA") - .withAddress(TenantInfoAddress.EMPTY + .withCode("3510") + .withRegion("Viken")) + .withBilling(TenantBilling.empty() + .withContact(TenantContact.from("Thomas The Tank Engine", "thomas@sodor.com", "NA")) + .withAddress(TenantAddress.empty() .withCity("Suddery") .withCountry("Sodor") - .withAddressLines("Central Station") - .withStateRegionProvince("Irish Sea"))); + .withAddress("Central Station") + .withRegion("Irish Sea"))); Slime slime = new Slime(); Cursor parentCursor = slime.setObject(); @@ -174,6 +172,19 @@ public class TenantSerializerTest { } @Test + public void cloud_tenant_with_tenant_info_contacts() { + TenantInfo tenantInfo = TenantInfo.empty() + .withContacts(new TenantContacts(List.of( + new TenantContacts.EmailContact(List.of(TenantContacts.Audience.TENANT), "email1@email.com"), + new TenantContacts.EmailContact(List.of(TenantContacts.Audience.TENANT, TenantContacts.Audience.NOTIFICATIONS), "email2@email.com")))); + Slime slime = new Slime(); + Cursor parentCursor = slime.setObject(); + serializer.toSlime(tenantInfo, parentCursor); + TenantInfo roundTripInfo = serializer.tenantInfoFromSlime(parentCursor.field("info")); + assertEquals(tenantInfo, roundTripInfo); + } + + @Test public void deleted_tenant() { DeletedTenant tenant = new DeletedTenant( TenantName.from("tenant1"), Instant.ofEpochMilli(1234L), Instant.ofEpochMilli(2345L)); diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiCloudTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiCloudTest.java index fa1483fd90f..c02e898b038 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiCloudTest.java +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiCloudTest.java @@ -6,8 +6,6 @@ import com.yahoo.config.provision.ApplicationId; import com.yahoo.config.provision.ApplicationName; import com.yahoo.config.provision.InstanceName; import com.yahoo.config.provision.TenantName; -import com.yahoo.slime.Slime; -import com.yahoo.slime.SlimeUtils; import com.yahoo.vespa.flags.InMemoryFlagSource; import com.yahoo.vespa.flags.PermanentFlags; import com.yahoo.vespa.hosted.controller.ControllerTester; @@ -32,7 +30,6 @@ import org.junit.Test; import javax.ws.rs.ForbiddenException; import java.io.File; -import java.io.IOException; import java.util.Collections; import java.util.Optional; import java.util.Set; @@ -89,19 +86,26 @@ public class ApplicationApiCloudTest extends ControllerContainerCloudTest { tester.assertResponse(infoRequest, "{}", 200); String partialInfo = "{\"contactName\":\"newName\", \"contactEmail\": \"foo@example.com\", \"billingContact\":{\"name\":\"billingName\"}}"; - var postPartial = request("/application/v4/tenant/scoober/info", PUT) .data(partialInfo) .roles(Set.of(Role.administrator(tenantName))); tester.assertResponse(postPartial, "{\"message\":\"Tenant info updated\"}", 200); + String partialContacts = "{\"contacts\": [{\"audiences\": [\"tenant\"],\"email\": \"contact1@example.com\"}]}"; + var postPartialContacts = + request("/application/v4/tenant/scoober/info", PUT) + .data(partialContacts) + .roles(Set.of(Role.administrator(tenantName))); + tester.assertResponse(postPartialContacts, "{\"message\":\"Tenant info updated\"}", 200); + // Read back the updated info - tester.assertResponse(infoRequest, "{\"name\":\"\",\"email\":\"\",\"website\":\"\",\"invoiceEmail\":\"\",\"contactName\":\"newName\",\"contactEmail\":\"foo@example.com\",\"billingContact\":{\"name\":\"billingName\",\"email\":\"\",\"phone\":\"\"}}", 200); + tester.assertResponse(infoRequest, "{\"name\":\"\",\"email\":\"\",\"website\":\"\",\"contactName\":\"newName\",\"contactEmail\":\"foo@example.com\",\"billingContact\":{\"name\":\"billingName\",\"email\":\"\",\"phone\":\"\"},\"contacts\":[{\"audiences\":[\"tenant\"],\"email\":\"contact1@example.com\"}]}", 200); String fullAddress = "{\"addressLines\":\"addressLines\",\"postalCodeOrZip\":\"postalCodeOrZip\",\"city\":\"city\",\"stateRegionProvince\":\"stateRegionProvince\",\"country\":\"country\"}"; String fullBillingContact = "{\"name\":\"name\",\"email\":\"foo@example\",\"phone\":\"phone\",\"address\":" + fullAddress + "}"; - String fullInfo = "{\"name\":\"name\",\"email\":\"foo@example\",\"website\":\"https://yahoo.com\",\"invoiceEmail\":\"invoiceEmail\",\"contactName\":\"contactName\",\"contactEmail\":\"contact@example.com\",\"address\":" + fullAddress + ",\"billingContact\":" + fullBillingContact + "}"; + String fullContacts = "[{\"audiences\":[\"tenant\"],\"email\":\"contact1@example.com\"},{\"audiences\":[\"notifications\"],\"email\":\"contact2@example.com\"},{\"audiences\":[\"tenant\",\"notifications\"],\"email\":\"contact3@example.com\"}]"; + String fullInfo = "{\"name\":\"name\",\"email\":\"foo@example\",\"website\":\"https://yahoo.com\",\"contactName\":\"contactName\",\"contactEmail\":\"contact@example.com\",\"address\":" + fullAddress + ",\"billingContact\":" + fullBillingContact + ",\"contacts\":" + fullContacts + "}"; // Now set all fields var postFull = @@ -168,6 +172,20 @@ public class ApplicationApiCloudTest extends ControllerContainerCloudTest { .roles(Set.of(Role.administrator(tenantName))); tester.assertResponse(addressInfoResponse, "{\"error-code\":\"BAD_REQUEST\",\"message\":\"All address fields must be set\"}", 400); + // at least one notification activity must be enabled + var contactsWithoutAudience = "{\"contacts\": [{\"email\": \"contact1@example.com\"}]}"; + var contactsWithoutAudienceResponse = request("/application/v4/tenant/scoober/info", PUT) + .data(contactsWithoutAudience) + .roles(Set.of(Role.administrator(tenantName))); + tester.assertResponse(contactsWithoutAudienceResponse, "{\"error-code\":\"BAD_REQUEST\",\"message\":\"at least one notification activity must be enabled\"}", 400); + + // email needs to be present, not blank, and contain an @ + var contactsWithInvalidEmail = "{\"contacts\": [{\"audiences\": [\"tenant\"],\"email\": \"contact1\"}]}"; + var contactsWithInvalidEmailResponse = request("/application/v4/tenant/scoober/info", PUT) + .data(contactsWithInvalidEmail) + .roles(Set.of(Role.administrator(tenantName))); + tester.assertResponse(contactsWithInvalidEmailResponse, "{\"error-code\":\"BAD_REQUEST\",\"message\":\"'email' needs to be an email address\"}", 400); + // updating a tenant that already has the fields set works var basicInfo = "{\"contactName\": \"Scoober Rentals Inc.\", \"contactEmail\": \"foo@example.com\"}"; var basicInfoResponse = request("/application/v4/tenant/scoober/info", PUT) diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/filter/SignatureFilterTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/filter/SignatureFilterTest.java index 7f588367819..15c7dbf73ab 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/filter/SignatureFilterTest.java +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/filter/SignatureFilterTest.java @@ -74,7 +74,7 @@ public class SignatureFilterTest { LastLoginInfo.EMPTY, Optional.empty(), ImmutableBiMap.of(), - TenantInfo.EMPTY, + TenantInfo.empty(), List.of(), Optional.empty())); tester.curator().writeApplication(new Application(appId, tester.clock().instant())); @@ -120,7 +120,7 @@ public class SignatureFilterTest { LastLoginInfo.EMPTY, Optional.empty(), ImmutableBiMap.of(publicKey, () -> "user"), - TenantInfo.EMPTY, + TenantInfo.empty(), List.of(), Optional.empty())); verifySecurityContext(requestOf(signer.signed(request.copy(), Method.POST, () -> new ByteArrayInputStream(hiBytes)), hiBytes), diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/responses/tenant-info-after-created.json b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/responses/tenant-info-after-created.json index 942b5c1db45..56104d626dd 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/responses/tenant-info-after-created.json +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/responses/tenant-info-after-created.json @@ -2,7 +2,7 @@ "name": "", "email": "", "website":"", - "invoiceEmail":"", "contactName": "administrator", - "contactEmail": "administrator@tenant" + "contactEmail": "administrator@tenant", + "contacts": [] }
\ No newline at end of file diff --git a/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/api/AthenzAssertion.java b/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/api/AthenzAssertion.java index 49cc31fe8c2..a343ea6e8f0 100644 --- a/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/api/AthenzAssertion.java +++ b/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/api/AthenzAssertion.java @@ -10,7 +10,14 @@ import java.util.OptionalLong; */ public class AthenzAssertion { - public enum Effect { ALLOW, DENY } + public enum Effect { + ALLOW, DENY; + + public static Effect valueOrNull(String value) { + try { return valueOf(value); } + catch (RuntimeException e) { return null; } + } + } private final Long id; private final Effect effect; diff --git a/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/client/zms/DefaultZmsClient.java b/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/client/zms/DefaultZmsClient.java index eef833c91a7..a6d18f3167c 100644 --- a/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/client/zms/DefaultZmsClient.java +++ b/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/client/zms/DefaultZmsClient.java @@ -229,7 +229,7 @@ public class DefaultZmsClient extends ClientBase implements ZmsClient { athenzDomain.getName(), athenzPolicy)); HttpUriRequest request = RequestBuilder.put() .setUri(uri) - .setEntity(toJsonStringEntity(new AssertionEntity(athenzRole.toResourceNameString(), resourceName.toResourceNameString(), action))) + .setEntity(toJsonStringEntity(new AssertionEntity(athenzRole.toResourceNameString(), resourceName.toResourceNameString(), action, "ALLOW"))) .build(); execute(request, response -> readEntity(response, Void.class)); } @@ -281,6 +281,7 @@ public class DefaultZmsClient extends ClientBase implements ZmsClient { AthenzResourceName.fromString(a.getResource()), a.getAction()) .id(a.getId()) + .effect(AthenzAssertion.Effect.valueOrNull(a.getEffect())) .build()) .collect(toList()); return Optional.of(new AthenzPolicy(entity.getName(), assertions)); diff --git a/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/client/zms/bindings/AssertionEntity.java b/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/client/zms/bindings/AssertionEntity.java index 4ef83760b5a..f0fe383a55b 100644 --- a/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/client/zms/bindings/AssertionEntity.java +++ b/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/client/zms/bindings/AssertionEntity.java @@ -17,20 +17,23 @@ public class AssertionEntity { private final String resource; private final String action; private final Long id; + private final String effect; - public AssertionEntity(String role, String resource, String action) { - this(role, resource, action, null); + public AssertionEntity(String role, String resource, String action, String effect) { + this(role, resource, action, null, effect); } public AssertionEntity(@JsonProperty("role") String role, @JsonProperty("resource") String resource, @JsonProperty("action") String action, - @JsonProperty("id") Long id) { + @JsonProperty("id") Long id, + @JsonProperty("effect") String effect) { this.role = role; this.resource = resource; this.action = action; this.id = id; + this.effect = effect; } public String getRole() { @@ -45,6 +48,10 @@ public class AssertionEntity { return action; } + public String getEffect() { + return effect; + } + @JsonIgnore public long getId() { return id; |