Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions lib/contracts/default.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
{ lib }:
let
inherit (lib) optionalAttrs;

mkContractFunctions =
{
mkConsumerOptions,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
mkConsumerOptions,
modulePath,
mkConsumerOptions,

mkProviderOptions,
}:
{
mkConsumer = inputDefaults: {
options = {
input = mkConsumerOptions inputDefaults;

output = mkProviderOptions { } // {
visible = "shallow";
};
};
};
Comment on lines +11 to +19

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

      mkConsumer = inputDefaults:
        lib.setDefaultModuleLocation modulePath {
          input = mkConsumerOptions inputDefaults;

          output = mkProviderOptions { } // {
            visible = "shallow";
          };
        };


mkProvider =
{
outputDefaults ? { },
providerOptions ? { },
}:
{
options = {
input = mkConsumerOptions { } // {
visible = "shallow";
};
}
// optionalAttrs (outputDefaults != { }) {
output = mkProviderOptions outputDefaults;
}
// optionalAttrs (providerOptions != { }) {
inherit providerOptions;
};
};

# Used for documentation
allOptions = {
input = mkConsumerOptions { };
output = mkProviderOptions { };
};
};

importContract =
module:
let
importedModule = import module { inherit lib; };
in
mkContractFunctions {
inherit (importedModule) mkConsumerOptions mkProviderOptions;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
inherit (importedModule) mkConsumerOptions mkProviderOptions;
modulePath = module;
inherit (importedModule) mkConsumerOptions mkProviderOptions;

}
// {
inherit (importedModule) description behaviorTest;
};
Comment on lines +47 to +57

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This loses the module's filepath, which'll lead to confusing messages for errors generated from within the contract's code.

Along with my other suggestion of changing mkConsumer & mkProvider to return modules, you could use lib.setDefaultModuleLocation to let the module system know where the code is from, which will then be used for error messages.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried setting an option wrong, specifically setting this one to true instead of a path to a file and got:

       … while evaluating the option `nodes.machine.testing.hardcoded-secret.mysecret.output.path':

       (stack trace truncated; use '--show-trace' to show the full, detailed trace)

       error: A definition for option `nodes.machine.testing.hardcoded-secret.mysecret.output.path' is not of type `string'. Definition values:
       - In `nixpkgs/nixos/modules/testing/hardcoded-secret.nix': true

It doesn't seem too bad to me. TBH after checking in nixpkgs I still have no idea how to use setDefaultModuleLocation.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean if the contract itself has a bug, say during development. Not the user code.
So for instance I added config.dummy = "plz-fail"; to the secret contract's mkConsumerOptions submodule type (here), and got this error:

error: The option `nodes.machine.testing.hardcoded-secret.mysecret.input.dummy' does not exist. Definition values:
- In `/nix/store/pcsb7z45hwi5y5yhl91slz1443p3i78f-source/nixos/modules/testing/hardcoded-secret.nix': "plz-fail"

It confusingly points to nixos/modules/testing/hardcoded-secret.nix since that's what the closest _file attr contains. When the error was actually in lib/contracts/secrets.nix.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See my other review for a partial fix.

in
{
filebackup = importContract ./filebackup.nix;
secrets = importContract ./secrets.nix;
}
177 changes: 177 additions & 0 deletions lib/contracts/filebackup.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
{ lib, ... }:
let
inherit (lib) mkOption;
inherit (lib.types)
listOf
nonEmptyListOf
submodule
str
;

coerce = x: y: if x != null then x else y;
in
{
description = ''
Contract for backing up files.

The consumer dictates what [folders to backup](#opt-contracts.filebackup.input.sourceDirectories)
and the provider will execute the backup
and store a snapshot in a backup repository.
The exact details on how to configure acces to the repository
is specific for each provider.

Every provider exposes a [backup systemd service](#opt-contracts.filebackup.output.backupService)
and a [script](#opt-contracts.filebackup.output.restoreScript) to list snaphosts
and restore from a given snapshot.
'';

mkConsumerOptions =
{
user ? "",
sourceDirectories ? [ "/var/lib/example" ],
sourceDirectoriesText ? null,
excludePatterns ? [ ],
beforeBackupHooks ? [ ],
afterBackupHooks ? [ ],
...
}:
mkOption {
description = ''
Consumer part of the [filebackup contract](#opt-contracts.filebackup).
'';

default = { };

type = submodule {
options = {
user = mkOption {
description = ''
Unix user doing the backup.
'';
type = str;
example = "vaultwarden";
default = user;
};

sourceDirectories = mkOption (
{
description = "Directories to backup.";
type = nonEmptyListOf str;
example = "/var/lib/vaultwarden";
default = sourceDirectories;
}
# This pattern is clumsy but necessary to support
# referencing to the config attrset from withing the options' defaults.
// lib.optionalAttrs (sourceDirectoriesText != null) {
defaultText = sourceDirectoriesText;
}
);

excludePatterns = mkOption {
description = "File patterns to exclude.";
type = listOf str;
default = excludePatterns;
};

beforeBackupHooks = mkOption {
description = "Hooks to run before backup.";
type = listOf str;
default = beforeBackupHooks;
};

afterBackupHooks = mkOption {
description = "Hooks to run after backup.";
type = listOf str;
default = afterBackupHooks;
};
};
};
};

mkProviderOptions =
{
restoreScript ? null,
restoreScriptText ? null,
backupService ? null,
backupServiceText ? null,
}:
let
fallbackRestoreName = "restoreScript";
fallbackBackupServiceName = "backup.service";
in
mkOption {
description = ''
Consumer part of the [filebackup contract](#opt-contracts.filebackup).
'';

default = { };

type = submodule {
options = {
restoreScript = mkOption (
{
description = ''
Name of script that can restore the given sourceDirectories.

To list snapshots, run:

```bash
$ ${coerce (coerce restoreScriptText restoreScript) fallbackRestoreName} snapshots
```

To restore a given snapshot, run:

```bash
$ ${coerce (coerce restoreScriptText restoreScript) fallbackRestoreName} restore latest
'';
type = str;
}
// (
if (restoreScript != null) then
{
default = restoreScript;
}
else
{
example = fallbackRestoreName;
}
)
# This pattern is clumsy but necessary to support
# referencing to the pkgs attrset from withing the options' defaults.
// lib.optionalAttrs (restoreScriptText != null) {
defaultText = restoreScriptText;
}
);

backupService =
mkOption {
description = ''
Name of service backing up the given sourceDirectories.

Usually, this service will be run at regular intervals thanks to a systemd timer.
This is dependent on the actual contract provider used.

It can also be ran manually with:

```bash
$ systemctl start ${coerce (coerce backupServiceText backupServiceText) fallbackBackupServiceName}
```
'';
type = str;
}
// (
if (backupService != null) then
{
default = backupService;
}
else
{
example = fallbackBackupServiceName;
}
);
};
};
};

behaviorTest = import ./filebackup/test.nix { inherit lib; };
}
133 changes: 133 additions & 0 deletions lib/contracts/filebackup/test.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
{
lib,
}:
let
inherit (lib) mkOption types;
in
{
name,
providerRoot,
extraModules ? [ ],
}:
{
name = "contracts_filebackup_${name}";

nodes.machine =
{ config, ... }:
{
imports = extraModules;

options.test = {
repository = mkOption {
type = types.str;
default = "/opt/repository";
};
username = mkOption {
type = types.str;
default = "me";
};
sourceDirectories = mkOption {
type = types.listOf types.str;
default = [
"/opt/files/A"
"/opt/files/B"
];
};
};

config = lib.mkMerge [
(lib.setAttrByPath providerRoot {
input = {
inherit (config.test) sourceDirectories;
user = config.test.username;
};
})
(lib.mkIf (config.test.username != "root") {
users.users.${config.test.username} = {
isSystemUser = true;
group = config.test.username;
};
users.groups.${config.test.username} = { };
})
];
};

extraPythonPackages = p: [ p.dictdiffer ];

testScript =
{ nodes, ... }:
let
cfg = nodes.machine;
inherit (lib.getAttrFromPath providerRoot nodes.machine) output;
in
''
from dictdiffer import diff # type: ignore

username = "${cfg.test.username}"
sourceDirectories = [ ${lib.concatMapStringsSep ", " (x: ''"${x}"'') cfg.test.sourceDirectories} ]

def list_files(dir):
files_and_content = {}

files = machine.succeed(f"""find {dir} -type f""").split("\n")[:-1]

for f in files:
content = machine.succeed(f"""cat {f}""").strip()
files_and_content[f] = content

return files_and_content

def assert_files(dir, files):
result = list(diff(list_files(dir), files))
if len(result) > 0:
raise Exception("Unexpected files:", result)

with subtest("Create initial content"):
for path in sourceDirectories:
machine.succeed(f"""
mkdir -p {path}
echo repo_fileA_1 > {path}/fileA
echo repo_fileB_1 > {path}/fileB

chown {username}: -R {path}
chmod go-rwx -R {path}
""")

for path in sourceDirectories:
assert_files(path, {
f'{path}/fileA': 'repo_fileA_1',
f'{path}/fileB': 'repo_fileB_1',
})

with subtest("First backup in repo"):
print(machine.succeed("systemctl cat ${output.backupService}"))
machine.succeed("systemctl start ${output.backupService}")

with subtest("New content"):
for path in sourceDirectories:
machine.succeed(f"""
echo repo_fileA_2 > {path}/fileA
echo repo_fileB_2 > {path}/fileB
""")

assert_files(path, {
f'{path}/fileA': 'repo_fileA_2',
f'{path}/fileB': 'repo_fileB_2',
})

with subtest("Delete content"):
for path in sourceDirectories:
machine.succeed(f"""rm -r {path}/*""")

assert_files(path, {})

with subtest("Restore initial content from repo"):
machine.succeed("""${output.restoreScript} restore latest""")

for path in sourceDirectories:
assert_files(path, {
f'{path}/fileA': 'repo_fileA_1',
f'{path}/fileB': 'repo_fileB_1',
})
'';
}
Loading
Loading