TimeZone: autoinstall and API (FR-1184) (#986)

* TimeZone: autoinstall and API

Add support for Get/Set timezone methods.  Get means that we inquire
with GeoIP as to which timezone is suggested.  Non-availability of
GeoIP, or a previous explicit Set, means that we return the system
timezone.  Set of timezone by Post results in set of the live system
timzeone, and queuing a set of the target system by way of cloud-init.

* Add clarifying comment about _request.
This commit is contained in:
Dan Bungert 2021-07-13 06:25:03 -06:00 committed by GitHub
parent c31e2a060a
commit 40945f1823
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 659 additions and 4 deletions

View File

@ -348,6 +348,361 @@
"additionalProperties": false
}
},
"timezone": {
"type": "string",
"enum": [
"",
"geoip",
"Africa/Abidjan",
"Africa/Accra",
"Africa/Algiers",
"Africa/Bissau",
"Africa/Cairo",
"Africa/Casablanca",
"Africa/Ceuta",
"Africa/El_Aaiun",
"Africa/Johannesburg",
"Africa/Juba",
"Africa/Khartoum",
"Africa/Lagos",
"Africa/Maputo",
"Africa/Monrovia",
"Africa/Nairobi",
"Africa/Ndjamena",
"Africa/Sao_Tome",
"Africa/Tripoli",
"Africa/Tunis",
"Africa/Windhoek",
"America/Adak",
"America/Anchorage",
"America/Araguaina",
"America/Argentina/Buenos_Aires",
"America/Argentina/Catamarca",
"America/Argentina/Cordoba",
"America/Argentina/Jujuy",
"America/Argentina/La_Rioja",
"America/Argentina/Mendoza",
"America/Argentina/Rio_Gallegos",
"America/Argentina/Salta",
"America/Argentina/San_Juan",
"America/Argentina/San_Luis",
"America/Argentina/Tucuman",
"America/Argentina/Ushuaia",
"America/Asuncion",
"America/Atikokan",
"America/Bahia",
"America/Bahia_Banderas",
"America/Barbados",
"America/Belem",
"America/Belize",
"America/Blanc-Sablon",
"America/Boa_Vista",
"America/Bogota",
"America/Boise",
"America/Cambridge_Bay",
"America/Campo_Grande",
"America/Cancun",
"America/Caracas",
"America/Cayenne",
"America/Chicago",
"America/Chihuahua",
"America/Costa_Rica",
"America/Creston",
"America/Cuiaba",
"America/Curacao",
"America/Danmarkshavn",
"America/Dawson",
"America/Dawson_Creek",
"America/Denver",
"America/Detroit",
"America/Edmonton",
"America/Eirunepe",
"America/El_Salvador",
"America/Fort_Nelson",
"America/Fortaleza",
"America/Glace_Bay",
"America/Goose_Bay",
"America/Grand_Turk",
"America/Guatemala",
"America/Guayaquil",
"America/Guyana",
"America/Halifax",
"America/Havana",
"America/Hermosillo",
"America/Indiana/Indianapolis",
"America/Indiana/Knox",
"America/Indiana/Marengo",
"America/Indiana/Petersburg",
"America/Indiana/Tell_City",
"America/Indiana/Vevay",
"America/Indiana/Vincennes",
"America/Indiana/Winamac",
"America/Inuvik",
"America/Iqaluit",
"America/Jamaica",
"America/Juneau",
"America/Kentucky/Louisville",
"America/Kentucky/Monticello",
"America/La_Paz",
"America/Lima",
"America/Los_Angeles",
"America/Maceio",
"America/Managua",
"America/Manaus",
"America/Martinique",
"America/Matamoros",
"America/Mazatlan",
"America/Menominee",
"America/Merida",
"America/Metlakatla",
"America/Mexico_City",
"America/Miquelon",
"America/Moncton",
"America/Monterrey",
"America/Montevideo",
"America/Nassau",
"America/New_York",
"America/Nipigon",
"America/Nome",
"America/Noronha",
"America/North_Dakota/Beulah",
"America/North_Dakota/Center",
"America/North_Dakota/New_Salem",
"America/Nuuk",
"America/Ojinaga",
"America/Panama",
"America/Pangnirtung",
"America/Paramaribo",
"America/Phoenix",
"America/Port-au-Prince",
"America/Port_of_Spain",
"America/Porto_Velho",
"America/Puerto_Rico",
"America/Punta_Arenas",
"America/Rainy_River",
"America/Rankin_Inlet",
"America/Recife",
"America/Regina",
"America/Resolute",
"America/Rio_Branco",
"America/Santarem",
"America/Santiago",
"America/Santo_Domingo",
"America/Sao_Paulo",
"America/Scoresbysund",
"America/Sitka",
"America/St_Johns",
"America/Swift_Current",
"America/Tegucigalpa",
"America/Thule",
"America/Thunder_Bay",
"America/Tijuana",
"America/Toronto",
"America/Vancouver",
"America/Whitehorse",
"America/Winnipeg",
"America/Yakutat",
"America/Yellowknife",
"Antarctica/Casey",
"Antarctica/Davis",
"Antarctica/DumontDUrville",
"Antarctica/Macquarie",
"Antarctica/Mawson",
"Antarctica/Palmer",
"Antarctica/Rothera",
"Antarctica/Syowa",
"Antarctica/Troll",
"Antarctica/Vostok",
"Asia/Almaty",
"Asia/Amman",
"Asia/Anadyr",
"Asia/Aqtau",
"Asia/Aqtobe",
"Asia/Ashgabat",
"Asia/Atyrau",
"Asia/Baghdad",
"Asia/Baku",
"Asia/Bangkok",
"Asia/Barnaul",
"Asia/Beirut",
"Asia/Bishkek",
"Asia/Brunei",
"Asia/Chita",
"Asia/Choibalsan",
"Asia/Colombo",
"Asia/Damascus",
"Asia/Dhaka",
"Asia/Dili",
"Asia/Dubai",
"Asia/Dushanbe",
"Asia/Famagusta",
"Asia/Gaza",
"Asia/Hebron",
"Asia/Ho_Chi_Minh",
"Asia/Hong_Kong",
"Asia/Hovd",
"Asia/Irkutsk",
"Asia/Jakarta",
"Asia/Jayapura",
"Asia/Jerusalem",
"Asia/Kabul",
"Asia/Kamchatka",
"Asia/Karachi",
"Asia/Kathmandu",
"Asia/Khandyga",
"Asia/Kolkata",
"Asia/Krasnoyarsk",
"Asia/Kuala_Lumpur",
"Asia/Kuching",
"Asia/Macau",
"Asia/Magadan",
"Asia/Makassar",
"Asia/Manila",
"Asia/Nicosia",
"Asia/Novokuznetsk",
"Asia/Novosibirsk",
"Asia/Omsk",
"Asia/Oral",
"Asia/Pontianak",
"Asia/Pyongyang",
"Asia/Qatar",
"Asia/Qostanay",
"Asia/Qyzylorda",
"Asia/Riyadh",
"Asia/Sakhalin",
"Asia/Samarkand",
"Asia/Seoul",
"Asia/Shanghai",
"Asia/Singapore",
"Asia/Srednekolymsk",
"Asia/Taipei",
"Asia/Tashkent",
"Asia/Tbilisi",
"Asia/Tehran",
"Asia/Thimphu",
"Asia/Tokyo",
"Asia/Tomsk",
"Asia/Ulaanbaatar",
"Asia/Urumqi",
"Asia/Ust-Nera",
"Asia/Vladivostok",
"Asia/Yakutsk",
"Asia/Yangon",
"Asia/Yekaterinburg",
"Asia/Yerevan",
"Atlantic/Azores",
"Atlantic/Bermuda",
"Atlantic/Canary",
"Atlantic/Cape_Verde",
"Atlantic/Faroe",
"Atlantic/Madeira",
"Atlantic/Reykjavik",
"Atlantic/South_Georgia",
"Atlantic/Stanley",
"Australia/Adelaide",
"Australia/Brisbane",
"Australia/Broken_Hill",
"Australia/Darwin",
"Australia/Eucla",
"Australia/Hobart",
"Australia/Lindeman",
"Australia/Lord_Howe",
"Australia/Melbourne",
"Australia/Perth",
"Australia/Sydney",
"Europe/Amsterdam",
"Europe/Andorra",
"Europe/Astrakhan",
"Europe/Athens",
"Europe/Belgrade",
"Europe/Berlin",
"Europe/Brussels",
"Europe/Bucharest",
"Europe/Budapest",
"Europe/Chisinau",
"Europe/Copenhagen",
"Europe/Dublin",
"Europe/Gibraltar",
"Europe/Helsinki",
"Europe/Istanbul",
"Europe/Kaliningrad",
"Europe/Kiev",
"Europe/Kirov",
"Europe/Lisbon",
"Europe/London",
"Europe/Luxembourg",
"Europe/Madrid",
"Europe/Malta",
"Europe/Minsk",
"Europe/Monaco",
"Europe/Moscow",
"Europe/Oslo",
"Europe/Paris",
"Europe/Prague",
"Europe/Riga",
"Europe/Rome",
"Europe/Samara",
"Europe/Saratov",
"Europe/Simferopol",
"Europe/Sofia",
"Europe/Stockholm",
"Europe/Tallinn",
"Europe/Tirane",
"Europe/Ulyanovsk",
"Europe/Uzhgorod",
"Europe/Vienna",
"Europe/Vilnius",
"Europe/Volgograd",
"Europe/Warsaw",
"Europe/Zaporozhye",
"Europe/Zurich",
"Indian/Chagos",
"Indian/Christmas",
"Indian/Cocos",
"Indian/Kerguelen",
"Indian/Mahe",
"Indian/Maldives",
"Indian/Mauritius",
"Indian/Reunion",
"Pacific/Apia",
"Pacific/Auckland",
"Pacific/Bougainville",
"Pacific/Chatham",
"Pacific/Chuuk",
"Pacific/Easter",
"Pacific/Efate",
"Pacific/Enderbury",
"Pacific/Fakaofo",
"Pacific/Fiji",
"Pacific/Funafuti",
"Pacific/Galapagos",
"Pacific/Gambier",
"Pacific/Guadalcanal",
"Pacific/Guam",
"Pacific/Honolulu",
"Pacific/Kiritimati",
"Pacific/Kosrae",
"Pacific/Kwajalein",
"Pacific/Majuro",
"Pacific/Marquesas",
"Pacific/Nauru",
"Pacific/Niue",
"Pacific/Norfolk",
"Pacific/Noumea",
"Pacific/Pago_Pago",
"Pacific/Palau",
"Pacific/Pitcairn",
"Pacific/Pohnpei",
"Pacific/Port_Moresby",
"Pacific/Rarotonga",
"Pacific/Tahiti",
"Pacific/Tarawa",
"Pacific/Tongatapu",
"Pacific/Wake",
"Pacific/Wallis",
"UTC"
]
},
"updates": {
"type": "string",
"enum": [

View File

@ -41,6 +41,7 @@ snaps:
- name: etcd
channel: 3.2/stable
updates: all
timezone: Pacific/Guam
storage:
config:
- {type: disk, ptable: gpt, path: /dev/vdb, wipe: superblock, preserve: false, grub_device: true, id: disk-1}

View File

@ -55,6 +55,7 @@ python3 scripts/check-yaml-fields.py .subiquity/subiquity-curtin-install.conf \
storage.config[-1].options='"errors=remount-ro"'
python3 scripts/check-yaml-fields.py <(python3 scripts/check-yaml-fields.py .subiquity/etc/cloud/cloud.cfg.d/99-installer.cfg datasource.None.userdata_raw) \
locale='"en_GB.UTF-8"' \
timezone='"Pacific/Guam"' \
'snap.commands=[snap install --channel=3.2/stable etcd]'
grep -q 'finish: subiquity/Install/install/postinstall/install_package1: SUCCESS: installing package1' \
.subiquity/subiquity-server-debug.log
@ -70,5 +71,8 @@ timeout --foreground 60 sh -c "LANG=C.UTF-8 python3 -m subiquity.cmd.tui --autoi
validate
grep -q 'finish: subiquity/Install/install/postinstall/run_unattended_upgrades: SUCCESS: downloading and installing security updates' .subiquity/subiquity-server-debug.log
python3 -m subiquity.cmd.schema > "$testschema"
diff -u "autoinstall-schema.json" "$testschema"
# Limit schema check to Focal+ - the timezone list on bionic is different
if (( $(echo "$(lsb_release -sr) >= 20.04" | bc -l) )); then
python3 -m subiquity.cmd.schema > "$testschema"
diff -u "autoinstall-schema.json" "$testschema"
fi

View File

@ -37,7 +37,7 @@ class ClickAction(argparse.Action):
def make_client_args_parser():
parser = argparse.ArgumentParser(
description='SUbiquity - Ubiquity for Servers',
description='Subiquity - Ubiquity for Servers',
prog='subiquity')
try:
ascii_default = os.ttyname(0) == "/dev/ttysclp0"

View File

@ -42,6 +42,7 @@ from subiquity.common.types import (
SSHData,
LiveSessionSSHInfo,
StorageResponse,
TimeZoneInfo,
WLANSupportInstallState,
ZdevInfo,
)
@ -226,6 +227,10 @@ class API:
class snap_info:
def GET(snap_name: str) -> SnapInfo: ...
class timezone:
def GET() -> TimeZoneInfo: ...
def POST(tz: str): ...
class reboot:
def POST(): ...

View File

@ -324,3 +324,9 @@ class SnapListResponse:
status: SnapCheckState
snaps: List[SnapInfo] = attr.Factory(list)
selections: List[SnapSelection] = attr.Factory(list)
@attr.s(auto_attribs=True)
class TimeZoneInfo:
timezone: str
from_geoip: bool

View File

@ -38,6 +38,7 @@ from .network import NetworkModel
from .proxy import ProxyModel
from .snaplist import SnapListModel
from .ssh import SSHModel
from .timezone import TimeZoneModel
from .updates import UpdatesModel
@ -84,6 +85,7 @@ POSTINSTALL_MODEL_NAMES = [
"packages",
"snaplist",
"ssh",
"timezone",
"userdata",
]
@ -121,6 +123,7 @@ class SubiquityModel:
self.proxy = ProxyModel()
self.snaplist = SnapListModel()
self.ssh = SSHModel()
self.timezone = TimeZoneModel()
self.updates = UpdatesModel()
self.userdata = {}

View File

@ -0,0 +1,63 @@
# Copyright 2021 Canonical, Ltd.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import logging
log = logging.getLogger('subiquity.models.timezone')
class TimeZoneModel(object):
""" Model representing timezone"""
timezone = ''
def __init__(self):
# This is the raw request from the API / autoinstall.
# Storing the raw request allows us to also report the requested
# value in /var/log/installer/autoinstall-user-data,
# so that if 'geoip' is requested, and multiple installs are
# done in different zones, they get a geoip-customized answer.
self._request = None
# The actually timezone to set, possibly post-geoip lookup or
# possibly manually specified.
self.timezone = ''
self.got_from_geoip = False
def set(self, value):
self._request = value
self.timezone = value if value != 'geoip' else ''
@property
def detect_with_geoip(self):
return self._request == 'geoip'
@property
def should_set_tz(self):
return bool(self.timezone)
@property
def request(self):
return self._request
def make_cloudconfig(self):
if not self.should_set_tz:
return {}
return {'timezone': self.timezone}
def __repr__(self):
return ("<TimeZone: request {} detect {} should_set {} " +
"timezone {} gfg {}>").format(
self.request, self.detect_with_geoip, self.should_set_tz,
self.timezone, self.got_from_geoip)

View File

@ -30,6 +30,7 @@ from .refresh import RefreshController
from .reporting import ReportingController
from .snaplist import SnapListController
from .ssh import SSHController
from .timezone import TimeZoneController
from .updates import UpdatesController
from .userdata import UserdataController
from .zdev import ZdevController
@ -54,6 +55,7 @@ __all__ = [
'ReportingController',
'SnapListController',
'SSHController',
'TimeZoneController',
'UpdatesController',
'UserdataController',
'ZdevController',

View File

@ -0,0 +1,120 @@
# Copyright 2021 Canonical, Ltd.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import logging
import subprocess
from subiquity.common.apidef import API
from subiquity.common.types import TimeZoneInfo
from subiquity.server.controller import SubiquityController
log = logging.getLogger('subiquity.server.controllers.timezone')
def generate_possible_tzs():
special_keys = ['', 'geoip']
tzcmd = ['timedatectl', 'list-timezones']
list_tz_out = subprocess.check_output(tzcmd, universal_newlines=True)
real_tzs = list_tz_out.splitlines()
return special_keys + real_tzs
def timedatectl_settz(tz):
tzcmd = ['timedatectl', 'set-timezone', tz]
try:
subprocess.run(tzcmd, universal_newlines=True)
except subprocess.CalledProcessError as cpe:
log.error('Failed to set live system timezone: %r', cpe)
def timedatectl_gettz():
# timedatectl show would be easier, but isn't on bionic
tzcmd = ['timedatectl', 'status']
env = {'LC_ALL': 'C'}
# ...
# Time zone: America/Denver (MDT, -0600)
# ...
try:
out = subprocess.check_output(tzcmd, env=env, universal_newlines=True)
for line in out.splitlines():
chunks = line.split(':')
label = chunks[0].strip()
if label != 'Time zone':
continue
chunks = chunks[1].split(' ')
return chunks[1]
except subprocess.CalledProcessError as cpe:
log.error('Failed to get live system timezone: %r', cpe)
except IndexError:
log.error('Failed to acquire system time zone')
log.debug('Failed to fine Time zone in timedatectl output')
return 'Etc/UTC'
class TimeZoneController(SubiquityController):
endpoint = API.timezone
possible = generate_possible_tzs()
autoinstall_key = model_name = 'timezone'
autoinstall_schema = {
'type': 'string',
'enum': possible
}
autoinstall_default = ''
relevant_variants = ('desktop', )
def load_autoinstall_data(self, data):
self.deserialize(data)
def make_autoinstall(self):
return self.serialize()
def serialize(self):
return self.model.request
def deserialize(self, data):
if data is None:
return
if data not in self.possible:
raise ValueError(f'Unrecognized time zone request "{data}"')
self.model.set(data)
if self.model.detect_with_geoip and self.app.geoip.timezone:
self.model.timezone = self.app.geoip.timezone
self.model.got_from_geoip = True
else:
self.model.got_from_geoip = False
self.set_system_timezone()
def set_system_timezone(self):
if self.model.should_set_tz:
timedatectl_settz(self.model.timezone)
async def GET(self) -> TimeZoneInfo:
if self.model.timezone:
return TimeZoneInfo(self.model.timezone,
self.model.got_from_geoip)
# a bare call to GET() is equivalent to autoinstall "timezone: geoip"
self.deserialize('geoip')
tz = self.model.timezone
if not tz:
tz = timedatectl_gettz()
return TimeZoneInfo(tz, self.model.got_from_geoip)
async def POST(self, tz: str):
self.deserialize(tz)

View File

@ -196,6 +196,7 @@ class SubiquityServer(Application):
"Identity",
"SSH",
"SnapList",
"TimeZone",
"Install",
"Updates",
"Late",

View File

@ -0,0 +1,95 @@
# Copyright 2021 Canonical, Ltd.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import mock
from subiquity.common.types import TimeZoneInfo
from subiquity.models.timezone import TimeZoneModel
from subiquity.server.controllers.timezone import TimeZoneController
from subiquitycore.tests import SubiTestCase
from subiquitycore.tests.mocks import make_app
from subiquitycore.tests.util import run_coro
class MockGeoIP:
text = ''
@property
def timezone(self):
return self.text
tz_denver = 'America/Denver'
tz_utc = 'Etc/UTC'
class TestTimeZoneController(SubiTestCase):
def setUp(self):
self.tzc_init()
def tzc_init(self):
self.tzc = TimeZoneController(make_app())
self.tzc.model = TimeZoneModel()
self.tzc.app.geoip = MockGeoIP()
self.tzc.app.geoip.text = tz_denver
@mock.patch('subiquity.server.controllers.timezone.timedatectl_settz')
@mock.patch('subiquity.server.controllers.timezone.timedatectl_gettz')
def test_good_tzs(self, tdc_gettz, tdc_settz):
tdc_gettz.return_value = tz_utc
goods = [
# val - autoinstall value
# | settz - should system set timezone
# | | geoip - did we get a value by geoip?
# | | | valid_lookup
('geoip', True, True, True),
('geoip', False, True, False),
# empty val is valid and means to set no time zone
('', False, False, True),
('Pacific/Auckland', True, False, False),
('America/Denver', True, False, False),
]
for val, settz, geoip, valid_lookup in goods:
self.tzc_init()
if not val or val == 'geoip':
if valid_lookup:
tz = TimeZoneInfo(tz_denver, True)
else:
tz = TimeZoneInfo(tz_utc, False)
self.tzc.app.geoip.text = ''
else:
tz = TimeZoneInfo(val, False)
self.tzc.deserialize(val)
self.assertEqual(val, self.tzc.serialize())
self.assertEqual(settz, self.tzc.model.should_set_tz,
self.tzc.model)
self.assertEqual(geoip, self.tzc.model.detect_with_geoip,
self.tzc.model)
self.assertEqual(tz, run_coro(self.tzc.GET()), self.tzc.model)
cloudconfig = {}
if self.tzc.model.should_set_tz:
cloudconfig = {'timezone': tz.timezone}
tdc_settz.assert_called_with(tz.timezone)
self.assertEqual(cloudconfig, self.tzc.model.make_cloudconfig(),
self.tzc.model)
def test_bad_tzs(self):
bads = [
'dhcp', # possible future value, not supported yet
'notatimezone',
]
for b in bads:
with self.assertRaises(ValueError):
self.tzc.deserialize(b)

View File

@ -17,4 +17,4 @@ import asyncio
def run_coro(coro):
asyncio.get_event_loop().run_until_complete(coro)
return asyncio.get_event_loop().run_until_complete(coro)