WIP: Settings/Inventory: deprecate settings.json #1672

Closed
hsjobeki wants to merge 1 commits from hsjobeki/clan-core:hsjobeki-main into main
13 changed files with 425 additions and 188 deletions

12
inventory.json Normal file
View File

@ -0,0 +1,12 @@
{
"machines": {
"foo": {
"name": "foo",
"system": "x86_64-linux",
"description": "A nice thing",
"icon": "./path/to/icon.png",
"tags": ["1", "2", "3"]
}
},
"services": {}
}

View File

@ -40,7 +40,7 @@ let
builtins.pathExists "${directory}/inventory.json"
# Is recursively applied. Any explicit nix will override.
then
lib.mkDefault (builtins.fromJSON (builtins.readFile "${directory}/inventory.json"))
(builtins.fromJSON (builtins.readFile "${directory}/inventory.json"))
else
{ }
)
@ -67,11 +67,27 @@ let
"clan"
"tags"
] [ ] config;
system = lib.attrByPath [
"nixpkgs"
"hostSystem"
] null config;
}
) machines;
}
# Will be deprecated
{ machines = lib.mapAttrs (_n: _: lib.mkDefault { }) machinesDirs; }
{
machines = lib.mapAttrs (
name: _:
# Use mkForce to make sure users migrate to the inventory system.
# When the settings.json exists the evaluation will print the deprecation warning.
lib.mkForce {
inherit name;
system = (machineSettings name).nixpkgs.hostSystem or null;
}
) machinesDirs;
}
# Deprecated interface
(if clanName != null then { meta.name = clanName; } else { })
@ -91,14 +107,24 @@ let
machineSettings =
machineName:
let
warn = lib.warn ''
Usage of Settings.json will be deprecated
!!! Consider migrating to the inventory system. !!!
File: ${directory + /machines/${machineName}/settings.json}
If there are still features missing in the inventory system, please open an issue on the clan-core repository.
'';
in
# CLAN_MACHINE_SETTINGS_FILE allows to override the settings file temporarily
# This is useful for doing a dry-run before writing changes into the settings.json
# Using CLAN_MACHINE_SETTINGS_FILE requires passing --impure to nix eval
if builtins.getEnv "CLAN_MACHINE_SETTINGS_FILE" != "" then
builtins.fromJSON (builtins.readFile (builtins.getEnv "CLAN_MACHINE_SETTINGS_FILE"))
warn (builtins.fromJSON (builtins.readFile (builtins.getEnv "CLAN_MACHINE_SETTINGS_FILE")))
else
lib.optionalAttrs (builtins.pathExists "${directory}/machines/${machineName}/settings.json") (
builtins.fromJSON (builtins.readFile (directory + /machines/${machineName}/settings.json))
warn (builtins.fromJSON (builtins.readFile (directory + /machines/${machineName}/settings.json)))
);
machineImports =

View File

@ -40,72 +40,81 @@ let
# For every machine in the inventory, build a NixOS configuration
# For each machine generate config, forEach service, if the machine is used.
builtins.mapAttrs (
machineName: _:
lib.foldlAttrs (
# [ Modules ], String, { ${instance_name} :: ServiceConfig }
acc: moduleName: serviceConfigs:
acc
# Collect service config
++ (lib.foldlAttrs (
# [ Modules ], String, ServiceConfig
acc2: instanceName: serviceConfig:
let
resolvedRoles = builtins.mapAttrs (
_roleName: members: resolveTags inventory members
) serviceConfig.roles;
machineName: machineConfig:
lib.foldlAttrs
(
# [ Modules ], String, { ${instance_name} :: ServiceConfig }
acc: moduleName: serviceConfigs:
acc
# Collect service config
++ (lib.foldlAttrs (
# [ Modules ], String, ServiceConfig
acc2: instanceName: serviceConfig:
let
resolvedRoles = builtins.mapAttrs (
_roleName: members: resolveTags inventory members
) serviceConfig.roles;
isInService = builtins.any (members: builtins.elem machineName members.machines) (
builtins.attrValues resolvedRoles
);
isInService = builtins.any (members: builtins.elem machineName members.machines) (
builtins.attrValues resolvedRoles
);
# Inverse map of roles. Allows for easy lookup of roles for a given machine.
# { ${machine_name} :: [roles]
inverseRoles = lib.foldlAttrs (
acc: roleName:
{ machines }:
acc
// builtins.foldl' (
acc2: machineName: acc2 // { ${machineName} = (acc.${machineName} or [ ]) ++ [ roleName ]; }
) { } machines
) { } resolvedRoles;
# Inverse map of roles. Allows for easy lookup of roles for a given machine.
# { ${machine_name} :: [roles]
inverseRoles = lib.foldlAttrs (
acc: roleName:
{ machines }:
acc
// builtins.foldl' (
acc2: machineName: acc2 // { ${machineName} = (acc.${machineName} or [ ]) ++ [ roleName ]; }
) { } machines
) { } resolvedRoles;
machineServiceConfig = (serviceConfig.machines.${machineName} or { }).config or { };
globalConfig = serviceConfig.config or { };
machineServiceConfig = (serviceConfig.machines.${machineName} or { }).config or { };
globalConfig = serviceConfig.config or { };
# TODO: maybe optimize this dont lookup the role in inverse roles. Imports are not lazy
roleModules = builtins.map (
role:
let
path = "${clan-core.clanModules.${moduleName}}/roles/${role}.nix";
in
if builtins.pathExists path then
path
else
throw "Module doesn't have role: '${role}'. Path: ${path} not found."
) inverseRoles.${machineName} or [ ];
in
if isInService then
acc2
++ [
{
imports = [ clan-core.clanModules.${moduleName} ] ++ roleModules;
config.clan.${moduleName} = lib.mkMerge [
globalConfig
machineServiceConfig
];
}
{
config.clan.inventory.services.${moduleName}.${instanceName} = {
roles = resolvedRoles;
# TODO: Add inverseRoles to the service config if needed
# inherit inverseRoles;
};
}
]
else
acc2
) [ ] serviceConfigs)
) [ ] inventory.services
# TODO: maybe optimize this dont lookup the role in inverse roles. Imports are not lazy
roleModules = builtins.map (
role:
let
path = "${clan-core.clanModules.${moduleName}}/roles/${role}.nix";
in
if builtins.pathExists path then
path
else
throw "Module doesn't have role: '${role}'. Path: ${path} not found."
) inverseRoles.${machineName} or [ ];
in
if isInService then
acc2
++ [
{
imports = [ clan-core.clanModules.${moduleName} ] ++ roleModules;
config.clan.${moduleName} = lib.mkMerge [
globalConfig
machineServiceConfig
];
}
{
config.clan.inventory.services.${moduleName}.${instanceName} = {
roles = resolvedRoles;
# TODO: Add inverseRoles to the service config if needed
# inherit inverseRoles;
};
}
]
else
acc2
) [ ] serviceConfigs)
)
# Machine configs
[
# Default machine config
(lib.optionalAttrs (machineConfig.system or null != null) {
config.nixpkgs.hostPlatform = machineConfig.system;
})
]
inventory.services
) inventory.machines or { };
in
machines

View File

@ -41,23 +41,34 @@ in
internal = true;
default = [ ];
};
config.assertions = lib.foldlAttrs (
ass1: serviceName: c:
ass1
++ lib.foldlAttrs (
ass2: instanceName: instanceConfig:
let
serviceMachineNames = lib.attrNames instanceConfig.machines;
topLevelMachines = lib.attrNames config.machines;
# All machines must be defined in the top-level machines
assertions = builtins.map (m: {
assertion = builtins.elem m topLevelMachines;
message = "${serviceName}.${instanceName}.machines.${m}. Should be one of [ ${builtins.concatStringsSep " | " topLevelMachines} ]";
}) serviceMachineNames;
in
ass2 ++ assertions
) [ ] c
) [ ] config.services;
config.assertions =
let
serviceAssertions = lib.foldlAttrs (
ass1: serviceName: c:
ass1
++ lib.foldlAttrs (
ass2: instanceName: instanceConfig:
let
serviceMachineNames = lib.attrNames instanceConfig.machines;
topLevelMachines = lib.attrNames config.machines;
# All machines must be defined in the top-level machines
assertions = builtins.map (m: {
assertion = builtins.elem m topLevelMachines;
message = "${serviceName}.${instanceName}.machines.${m}. Should be one of [ ${builtins.concatStringsSep " | " topLevelMachines} ]";
}) serviceMachineNames;
in
ass2 ++ assertions
) [ ] c
) [ ] config.services;
machineAssertions = map (
{ name, value }:
{
assertion = true;
message = "Machine ${name} should define its host system in the inventory. ()";
}
) (lib.attrsToList (lib.filterAttrs (_n: v: v.system or null == null) config.machines));
in
machineAssertions ++ serviceAssertions;
options.meta = metaOptions;
@ -72,6 +83,10 @@ in
apply = lib.unique;
type = t.listOf t.str;
};
system = lib.mkOption {
default = null;
type = t.nullOr t.str;
};
};
}
);

View File

@ -14,6 +14,7 @@ self.lib.buildClan {
};
};
};
# inventory = builtins.fromJSON (builtins.readFile "${self}/inventory.json");
# merged with
machines = {

View File

@ -22,7 +22,7 @@ package schema
machines: [...string],
tags: [...string],
}
machines: {
machines?: {
[string]: {
config?: {
...
@ -30,8 +30,8 @@ package schema
}
},
// Configuration for the service
config: {
// Global Configuration for the service
config?: {
// Schema depends on the module.
// It declares the interface how the service can be configured.
...

View File

@ -2,7 +2,6 @@
import argparse
import json
import logging
import os
import re
import sys
from pathlib import Path
@ -10,9 +9,7 @@ from typing import Any, get_origin
from clan_cli.cmd import run
from clan_cli.completions import add_dynamic_completer, complete_machines
from clan_cli.dirs import machine_settings_file
from clan_cli.errors import ClanError
from clan_cli.git import commit_file
from clan_cli.nix import nix_eval
script_dir = Path(__file__).parent
@ -177,17 +174,17 @@ def get_or_set_option(args: argparse.Namespace) -> None:
with open(args.options_file) as f:
options = json.load(f)
# compute settings json file location
if args.settings_file is None:
settings_file = machine_settings_file(Path(args.flake), args.machine)
else:
settings_file = args.settings_file
# if args.settings_file is None:
# settings_file = machine_settings_file(Path(args.flake), args.machine)
# else:
# settings_file = args.settings_file
# set the option with the given value
set_option(
flake_dir=Path(args.flake),
option=args.option,
value=args.value,
options=options,
settings_file=settings_file,
# settings_file=settings_file,
option_description=args.option,
show_trace=args.show_trace,
)
@ -253,7 +250,7 @@ def set_option(
option: str,
value: Any,
options: dict,
settings_file: Path,
# settings_file: Path,
option_description: str = "",
show_trace: bool = False,
) -> None:
@ -286,25 +283,25 @@ def set_option(
current[option_path_store[-1]] = casted
# check if there is an existing config file
if os.path.exists(settings_file):
with open(settings_file) as f:
current_config = json.load(f)
else:
current_config = {}
# if os.path.exists(settings_file):
# with open(settings_file) as f:
# current_config = json.load(f)
# else:
# current_config = {}
# merge and save the new config file
new_config = merge(current_config, result)
settings_file.parent.mkdir(parents=True, exist_ok=True)
with open(settings_file, "w") as f:
json.dump(new_config, f, indent=2)
print(file=f) # add newline at the end of the file to make git happy
# new_config = merge(current_config, result)
# settings_file.parent.mkdir(parents=True, exist_ok=True)
# with open(settings_file, "w") as f:
# json.dump(new_config, f, indent=2)
# print(file=f) # add newline at the end of the file to make git happy
if settings_file.resolve().is_relative_to(flake_dir):
commit_file(
settings_file,
repo_dir=flake_dir,
commit_message=f"Set option {option_description}",
)
# if settings_file.resolve().is_relative_to(flake_dir):
# commit_file(
# settings_file,
# repo_dir=flake_dir,
# commit_message=f"Set option {option_description}",
# )
# takes a (sub)parser and configures it

View File

@ -1,13 +1,10 @@
import json
import os
import re
from pathlib import Path
from tempfile import NamedTemporaryFile
from clan_cli.cmd import Log, run
from clan_cli.dirs import machine_settings_file, nixpkgs_source, specific_machine_dir
from clan_cli.errors import ClanError, ClanHttpError
from clan_cli.git import commit_file
from clan_cli.dirs import nixpkgs_source
from clan_cli.nix import nix_eval
@ -21,13 +18,15 @@ def verify_machine_config(
Returns a tuple of (success, error_message)
"""
if config is None:
config = config_for_machine(flake_dir, machine_name)
# TODO: @davHau - Migrate this to inventory.
config = {}
# config = config_for_machine(flake_dir, machine_name)
flake = flake_dir
with NamedTemporaryFile(mode="w", dir=flake) as clan_machine_settings_file:
json.dump(config, clan_machine_settings_file, indent=2)
clan_machine_settings_file.seek(0)
env = os.environ.copy()
env["CLAN_MACHINE_SETTINGS_FILE"] = clan_machine_settings_file.name
# env["CLAN_MACHINE_SETTINGS_FILE"] = clan_machine_settings_file.name
cmd = nix_eval(
flags=[
"--show-trace",
@ -72,37 +71,37 @@ def verify_machine_config(
return None
def config_for_machine(flake_dir: Path, machine_name: str) -> dict:
# read the config from a json file located at {flake}/machines/{machine_name}/settings.json
if not specific_machine_dir(flake_dir, machine_name).exists():
raise ClanHttpError(
msg=f"Machine {machine_name} not found. Create the machine first`",
status_code=404,
)
settings_path = machine_settings_file(flake_dir, machine_name)
if not settings_path.exists():
return {}
with open(settings_path) as f:
return json.load(f)
# def config_for_machine(flake_dir: Path, machine_name: str) -> dict:
# read the config from a json file located at {flake}/machines/{machine_name}/settings.json
# if not specific_machine_dir(flake_dir, machine_name).exists():
# raise ClanHttpError(
# msg=f"Machine {machine_name} not found. Create the machine first`",
# status_code=404,
# )
# settings_path = machine_settings_file(flake_dir, machine_name)
# if not settings_path.exists():
# return {}
# with open(settings_path) as f:
# return json.load(f)
def set_config_for_machine(flake_dir: Path, machine_name: str, config: dict) -> None:
hostname_regex = r"^(?!-)[A-Za-z0-9-]{1,63}(?<!-)$"
if not re.match(hostname_regex, machine_name):
raise ClanError("Machine name must be a valid hostname")
if "networking" in config and "hostName" in config["networking"]:
if machine_name != config["networking"]["hostName"]:
raise ClanHttpError(
msg="Machine name does not match the 'networking.hostName' setting in the config",
status_code=400,
)
config["networking"]["hostName"] = machine_name
# create machine folder if it doesn't exist
# write the config to a json file located at {flake}/machines/{machine_name}/settings.json
settings_path = machine_settings_file(flake_dir, machine_name)
settings_path.parent.mkdir(parents=True, exist_ok=True)
with open(settings_path, "w") as f:
json.dump(config, f)
# def set_config_for_machine(flake_dir: Path, machine_name: str, config: dict) -> None:
# hostname_regex = r"^(?!-)[A-Za-z0-9-]{1,63}(?<!-)$"
# if not re.match(hostname_regex, machine_name):
# raise ClanError("Machine name must be a valid hostname")
# if "networking" in config and "hostName" in config["networking"]:
# if machine_name != config["networking"]["hostName"]:
# raise ClanHttpError(
# msg="Machine name does not match the 'networking.hostName' setting in the config",
# status_code=400,
# )
# config["networking"]["hostName"] = machine_name
# # create machine folder if it doesn't exist
# # write the config to a json file located at {flake}/machines/{machine_name}/settings.json
# settings_path = machine_settings_file(flake_dir, machine_name)
# settings_path.parent.mkdir(parents=True, exist_ok=True)
# with open(settings_path, "w") as f:
# json.dump(config, f)
if flake_dir is not None:
commit_file(settings_path, flake_dir)
# if flake_dir is not None:
# commit_file(settings_path, flake_dir)

View File

@ -103,10 +103,6 @@ def specific_machine_dir(flake_dir: Path, machine: str) -> Path:
return machines_dir(flake_dir) / machine
def machine_settings_file(flake_dir: Path, machine: str) -> Path:
return specific_machine_dir(flake_dir, machine) / "settings.json"
def module_root() -> Path:
return Path(__file__).parent

View File

@ -0,0 +1,138 @@
import re
from dataclasses import asdict, dataclass, is_dataclass
from pathlib import Path
from typing import Any, Literal
from clan_cli.errors import ClanError
def sanitize_string(s: str) -> str:
return s.replace("\\", "\\\\").replace('"', '\\"')
def dataclass_to_dict(obj: Any) -> Any:
"""
Utility function to convert dataclasses to dictionaries
It converts all nested dataclasses, lists, tuples, and dictionaries to dictionaries
It does NOT convert member functions.
"""
if is_dataclass(obj):
return {
sanitize_string(k): dataclass_to_dict(v)
for k, v in asdict(obj).items() # type: ignore
}
elif isinstance(obj, list | tuple):
return [dataclass_to_dict(item) for item in obj]
elif isinstance(obj, dict):
return {sanitize_string(k): dataclass_to_dict(v) for k, v in obj.items()}
elif isinstance(obj, Path):
return str(obj)
elif isinstance(obj, str):
return sanitize_string(obj)
else:
return obj
@dataclass
class Machine:
"""
Inventory machine model.
DO NOT EDIT THIS CLASS.
Any changes here must be reflected in the inventory interface file and potentially other nix files.
- Persisted to the inventory.json file
- Source of truth to generate each clan machine.
- For hardware deployment, the machine must declare the host system.
"""
name: str
system: Literal["x86_64-linux"] | str | None = None
description: str | None = None
icon: str | None = None
tags: list[str] | None = None
@staticmethod
def from_dict(d: dict[str, Any]) -> "Machine":
if "name" not in d:
raise ClanError("name not found in machine")
hostname_regex = r"^(?!-)[A-Za-z0-9-]{1,63}(?<!-)$"
if not re.match(hostname_regex, d["name"]):
raise ClanError(
"Machine name must be a valid hostname",
description=f"""Machine name: {d["name"]}""",
)
return Machine(**d)
@dataclass
class MachineServiceConfig:
config: dict[str, Any] | None = None
@dataclass
class ServiceMeta:
name: str
description: str | None = None
icon: str | None = None
@dataclass
class Role:
machines: list[str] | None = None
tags: list[str] | None = None
@dataclass
class Service:
meta: ServiceMeta
roles: dict[str, Role]
machines: dict[str, MachineServiceConfig] | None = None
@staticmethod
def from_dict(d: dict[str, Any]) -> "Service":
if "meta" not in d:
raise ClanError("meta not found in service")
if "roles" not in d:
raise ClanError("roles not found in service")
return Service(
meta=ServiceMeta(**d["meta"]),
roles={name: Role(**role) for name, role in d["roles"].items()},
machines={
name: MachineServiceConfig(**machine)
for name, machine in d.get("machines", {}).items()
},
)
@dataclass
class Inventory:
machines: dict[str, Machine]
services: dict[str, dict[str, Service]]
@staticmethod
def from_dict(d: dict[str, Any]) -> "Inventory":
if "machines" not in d:
raise ClanError("machines not found in inventory")
if "services" not in d:
raise ClanError("services not found in inventory")
return Inventory(
machines={
name: Machine.from_dict(machine)
for name, machine in d["machines"].items()
},
services={
name: {
role: Service.from_dict(service)
for role, service in services.items()
}
for name, services in d["services"].items()
},
)

View File

@ -1,30 +1,81 @@
import argparse
import json
import logging
from dataclasses import dataclass
import re
from pathlib import Path
from typing import Any
from clan_cli.api import API
from clan_cli.config.machine import set_config_for_machine
from clan_cli.errors import ClanError
from clan_cli.inventory import Inventory, Machine, dataclass_to_dict
log = logging.getLogger(__name__)
@dataclass
class MachineCreateRequest:
name: str
config: dict[str, Any]
@API.register
def create_machine(flake_dir: str | Path, machine: MachineCreateRequest) -> None:
set_config_for_machine(Path(flake_dir), machine.name, machine.config)
def create_machine(flake_dir: str | Path, machine: Machine) -> None:
hostname_regex = r"^(?!-)[A-Za-z0-9-]{1,63}(?<!-)$"
if not re.match(hostname_regex, machine.name):
raise ClanError("Machine name must be a valid hostname")
inventory = Inventory(machines={}, services={})
inventory_file = Path(flake_dir) / "inventory.json"
if inventory_file.exists():
with open(inventory_file) as f:
try:
res = json.load(f)
inventory = Inventory.from_dict(res)
except json.JSONDecodeError as e:
raise ClanError(f"Error decoding inventory file: {e}")
inventory.machines.update({machine.name: machine})
with open(inventory_file, "w") as g:
d = dataclass_to_dict(inventory)
json.dump(d, g, indent=2)
def create_command(args: argparse.Namespace) -> None:
create_machine(args.flake, MachineCreateRequest(args.machine, dict()))
create_machine(
args.flake,
Machine(
name=args.machine,
system=args.system,
description=args.description,
tags=args.tags,
icon=args.icon,
),
)
def register_create_parser(parser: argparse.ArgumentParser) -> None:
parser.add_argument("machine", type=str)
parser.set_defaults(func=create_command)
parser.add_argument(
"--system",
type=str,
default=None,
help="Host platform to use. i.e. 'x86_64-linux' or 'aarch64-darwin' etc.",
metavar="PLATFORM",
)
parser.add_argument(
"--description",
type=str,
default=None,
help="A description of the machine.",
)
parser.add_argument(
"--icon",
type=str,
default=None,
help="Path to an icon to use for the machine. - Must be a path to icon file relative to the flake directory, or a public url.",
metavar="PATH",
)
parser.add_argument(
"--tags",
nargs="+",
default=[],
help="Tags to associate with the machine. Can be used to assign multiple machines to services.",
)

View File

@ -1,5 +1,4 @@
import fileinput
import json
import logging
import os
import shutil
@ -86,10 +85,10 @@ def generate_flake(
print(line, end="")
# generate machines from machineConfigs
for machine_name, machine_config in machine_configs.items():
settings_path = flake / "machines" / machine_name / "settings.json"
settings_path.parent.mkdir(parents=True, exist_ok=True)
settings_path.write_text(json.dumps(machine_config, indent=2))
# for machine_name, machine_config in machine_configs.items():
# settings_path = flake / "machines" / machine_name / "settings.json"
# settings_path.parent.mkdir(parents=True, exist_ok=True)
# settings_path.write_text(json.dumps(machine_config, indent=2))
if "/tmp" not in str(os.environ.get("HOME")):
log.warning(
@ -148,10 +147,10 @@ def create_flake(
substitute(flake / "machines" / machine_name / "default.nix", flake)
# generate machines from machineConfigs
for machine_name, machine_config in machine_configs.items():
settings_path = flake / "machines" / machine_name / "settings.json"
settings_path.parent.mkdir(parents=True, exist_ok=True)
settings_path.write_text(json.dumps(machine_config, indent=2))
# for machine_name, machine_config in machine_configs.items():
# settings_path = flake / "machines" / machine_name / "settings.json"
# settings_path.parent.mkdir(parents=True, exist_ok=True)
# settings_path.write_text(json.dumps(machine_config, indent=2))
# in the flake.nix file replace the string __CLAN_URL__ with the the clan flake
# provided by get_test_flake_toplevel

View File

@ -1,13 +1,9 @@
import pytest
from fixtures_flakes import FlakeForTest
from clan_cli.config.machine import (
config_for_machine,
set_config_for_machine,
verify_machine_config,
)
from clan_cli.config.schema import machine_schema
from clan_cli.machines.create import MachineCreateRequest, create_machine
from clan_cli.inventory import Machine
from clan_cli.machines.create import create_machine
from clan_cli.machines.list import list_machines
@ -22,14 +18,12 @@ def test_create_machine_on_minimal_clan(test_flake_minimal: FlakeForTest) -> Non
assert list_machines(test_flake_minimal.path) == []
create_machine(
test_flake_minimal.path,
MachineCreateRequest(
name="foo", config=dict(nixpkgs=dict(hostSystem="x86_64-linux"))
),
Machine(name="foo", system="x86_64-linux"),
)
assert list_machines(test_flake_minimal.path) == ["foo"]
set_config_for_machine(
test_flake_minimal.path, "foo", dict(services=dict(openssh=dict(enable=True)))
)
config = config_for_machine(test_flake_minimal.path, "foo")
assert config["services"]["openssh"]["enable"]
assert verify_machine_config(test_flake_minimal.path, "foo") is None
# set_config_for_machine(
# test_flake_minimal.path, "foo", dict(services=dict(openssh=dict(enable=True)))
# )
# config = config_for_machine(test_flake_minimal.path, "foo")
# assert config["services"]["openssh"]["enable"]
# assert verify_machine_config(test_flake_minimal.path, "foo") is None