1
0
Fork 0
mirror of https://github.com/Mic92/sops-nix.git synced 2024-12-14 11:57:52 +00:00

rebase, complete implementation

This commit is contained in:
Jeremy Fleischman 2024-11-05 11:46:26 -06:00 committed by mergify[bot]
parent bb7d636211
commit aa5caa129b
5 changed files with 149 additions and 208 deletions

View file

@ -16,6 +16,7 @@ writeTextFile {
ageSshKeyPaths = cfg.age.sshKeyPaths; ageSshKeyPaths = cfg.age.sshKeyPaths;
useTmpfs = cfg.useTmpfs; useTmpfs = cfg.useTmpfs;
templates = cfg.templates; templates = cfg.templates;
placeholderBySecretName = cfg.placeholder;
userMode = false; userMode = false;
logging = { logging = {
keyImport = builtins.elem "keyImport" cfg.log; keyImport = builtins.elem "keyImport" cfg.log;

View file

@ -1,40 +1,21 @@
{ config, pkgs, lib, options, ... }: { config, pkgs, lib, options, ... }:
with lib;
with lib.types;
with builtins;
let let
cfg = config.sops; inherit (lib)
secretsForUsers = lib.filterAttrs (_: v: v.neededForUsers) cfg.secrets; mkOption
mkDefault
mapAttrs
types
;
users = config.users.users; users = config.users.users;
useSystemdActivation = (options.systemd ? sysusers && config.systemd.sysusers.enable) ||
(options.services ? userborn && config.services.userborn.enable);
renderScript = ''
echo Setting up sops templates...
${concatMapStringsSep "\n" (name:
let
tpl = config.sops.templates.${name};
substitute = pkgs.writers.writePython3 "substitute" { }
(readFile ./subs.py);
subst-pairs = pkgs.writeText "pairs" (concatMapStringsSep "\n"
(name:
"${toString config.sops.placeholder.${name}} ${
config.sops.secrets.${name}.path
}") (attrNames config.sops.secrets));
in ''
mkdir -p "${dirOf tpl.path}"
(umask 077; ${substitute} ${tpl.file} ${subst-pairs} > ${tpl.path})
chmod "${tpl.mode}" "${tpl.path}"
chown "${tpl.owner}:${tpl.group}" "${tpl.path}"
'') (attrNames config.sops.templates)}
'';
in { in {
options.sops = { options.sops = {
templates = mkOption { templates = mkOption {
description = "Templates for secret files"; description = "Templates for secret files";
type = attrsOf (submodule ({ config, ... }: { type = types.attrsOf (types.submodule ({ config, ... }: {
options = { options = {
name = mkOption { name = mkOption {
type = singleLineStr; type = types.singleLineStr;
default = config._module.args.name; default = config._module.args.name;
description = '' description = ''
Name of the file used in /run/secrets/rendered Name of the file used in /run/secrets/rendered
@ -42,32 +23,32 @@ in {
}; };
path = mkOption { path = mkOption {
description = "Path where the rendered file will be placed"; description = "Path where the rendered file will be placed";
type = singleLineStr; type = types.singleLineStr;
default = "/run/secrets/rendered/${config.name}"; default = "/run/secrets/rendered/${config.name}";
}; };
content = mkOption { content = mkOption {
type = lines; type = types.lines;
default = ""; default = "";
description = '' description = ''
Content of the file Content of the file
''; '';
}; };
mode = mkOption { mode = mkOption {
type = singleLineStr; type = types.singleLineStr;
default = "0400"; default = "0400";
description = '' description = ''
Permissions mode of the rendered secret file in octal. Permissions mode of the rendered secret file in octal.
''; '';
}; };
owner = mkOption { owner = mkOption {
type = singleLineStr; type = types.singleLineStr;
default = "root"; default = "root";
description = '' description = ''
User of the file. User of the file.
''; '';
}; };
group = mkOption { group = mkOption {
type = singleLineStr; type = types.singleLineStr;
default = users.${config.owner}.group; default = users.${config.owner}.group;
defaultText = lib.literalExpression ''config.users.users.''${cfg.owner}.group''; defaultText = lib.literalExpression ''config.users.users.''${cfg.owner}.group'';
description = '' description = ''
@ -88,40 +69,21 @@ in {
default = { }; default = { };
}; };
placeholder = mkOption { placeholder = mkOption {
type = attrsOf (mkOptionType { type = types.attrsOf (types.mkOptionType {
name = "coercibleToString"; name = "coercibleToString";
description = "value that can be coerced to string"; description = "value that can be coerced to string";
check = strings.isConvertibleWithToString; check = lib.strings.isConvertibleWithToString;
merge = mergeEqualOption; merge = lib.mergeEqualOption;
}); });
default = { }; default = { };
visible = false; visible = false;
}; };
}; };
config = optionalAttrs (options ? sops.secrets) config = lib.optionalAttrs (options ? sops.secrets)
(mkIf (config.sops.templates != { }) { (lib.mkIf (config.sops.templates != { }) {
sops.placeholder = mapAttrs sops.placeholder = mapAttrs
(name: _: mkDefault "<SOPS:${hashString "sha256" name}:PLACEHOLDER>") (name: _: mkDefault "<SOPS:${builtins.hashString "sha256" name}:PLACEHOLDER>")
config.sops.secrets; config.sops.secrets;
systemd.services.sops-render-secrets = let
installServices = [ "sops-install-secrets.service" ] ++ optional (secretsForUsers != { }) "sops-install-secrets-for-users.service";
in lib.mkIf (cfg.templates != { } && useSystemdActivation) {
wantedBy = [ "sysinit.target" ];
requires = installServices;
after = installServices;
unitConfig.DefaultDependencies = "no";
script = renderScript;
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
};
system.activationScripts.renderSecrets = mkIf (cfg.templates != { } && !useSystemdActivation)
(stringAfter ([ "setupSecrets" ] ++ optional (secretsForUsers != { }) "setupSecretsForUsers")
renderScript);
}); });
} }

View file

@ -1,26 +0,0 @@
from sys import argv
def substitute(target: str, subst: str) -> str:
with open(target) as f:
content = f.read()
with open(subst) as f:
subst_pairs = f.read().splitlines()
for pair in subst_pairs:
placeholder, path = pair.split()
if placeholder in content:
with open(path) as f:
content = content.replace(placeholder, f.read())
return content
def main() -> None:
target = argv[1]
subst = argv[2]
print(substitute(target, subst))
main()

View file

@ -11,7 +11,6 @@ import (
"os/user" "os/user"
"path" "path"
"path/filepath" "path/filepath"
"regexp"
"strconv" "strconv"
"strings" "strings"
"syscall" "syscall"
@ -55,18 +54,22 @@ type template struct {
Content string `json:"content"` Content string `json:"content"`
Path string `json:"path"` Path string `json:"path"`
Mode string `json:"mode"` Mode string `json:"mode"`
Owner string `json:"owner"` Owner *string `json:"owner,omitempty"`
Group string `json:"group"` UID int `json:"uid"`
Group *string `json:"group,omitempty"`
GID int `json:"gid"`
File string `json:"file"` File string `json:"file"`
value []byte value []byte
mode os.FileMode mode os.FileMode
content string
owner int owner int
group int group int
} }
type manifest struct { type manifest struct {
Secrets []secret `json:"secrets"` Secrets []secret `json:"secrets"`
Templates []template `json:"templates"` Templates map[string]*template `json:"templates"`
PlaceholderBySecretName map[string]string `json:"placeholderBySecretName"`
SecretsMountPoint string `json:"secretsMountPoint"` SecretsMountPoint string `json:"secretsMountPoint"`
SymlinkPath string `json:"symlinkPath"` SymlinkPath string `json:"symlinkPath"`
KeepGenerations int `json:"keepGenerations"` KeepGenerations int `json:"keepGenerations"`
@ -146,7 +149,7 @@ type options struct {
type appContext struct { type appContext struct {
manifest manifest manifest manifest
secretFiles map[string]secretFile secretFiles map[string]secretFile
placeholder map[string]secret secretByPlaceholder map[string]*secret
checkMode CheckMode checkMode CheckMode
ignorePasswd bool ignorePasswd bool
} }
@ -363,28 +366,28 @@ func prepareSecretsDir(secretMountpoint string, linkName string, keysGID int, us
return &dir, nil return &dir, nil
} }
func createParentDirs(parent string, target string, keysGid int, userMode bool) error { func createParentDirs(parent string, target string, keysGID int, userMode bool) error {
dirs := strings.Split(filepath.Dir(target), "/") dirs := strings.Split(filepath.Dir(target), "/")
pathSoFar := parent pathSoFar := parent
for _, dir := range dirs { for _, dir := range dirs {
pathSoFar = filepath.Join(pathSoFar, dir) pathSoFar = filepath.Join(pathSoFar, dir)
if err := os.MkdirAll(pathSoFar, 0o751); err != nil { if err := os.MkdirAll(pathSoFar, 0o751); err != nil {
return fmt.Errorf("Cannot create directory '%s' for %s: %w", pathSoFar, filepath.Join(parent, target), err) return fmt.Errorf("cannot create directory '%s' for %s: %w", pathSoFar, filepath.Join(parent, target), err)
} }
if !userMode { if !userMode {
if err := os.Chown(pathSoFar, 0, int(keysGid)); err != nil { if err := os.Chown(pathSoFar, 0, int(keysGID)); err != nil {
return fmt.Errorf("Cannot own directory '%s' for %s: %w", pathSoFar, filepath.Join(parent, target), err) return fmt.Errorf("cannot own directory '%s' for %s: %w", pathSoFar, filepath.Join(parent, target), err)
} }
} }
} }
return nil return nil
} }
func writeSecrets(secretDir string, secrets []secret, keysGid int, userMode bool) error { func writeSecrets(secretDir string, secrets []secret, keysGID int, userMode bool) error {
for _, secret := range secrets { for _, secret := range secrets {
fp := filepath.Join(secretDir, secret.Name) fp := filepath.Join(secretDir, secret.Name)
if err := createParentDirs(secretDir, secret.Name, keysGid, userMode); err != nil { if err := createParentDirs(secretDir, secret.Name, keysGID, userMode); err != nil {
return err return err
} }
if err := os.WriteFile(fp, []byte(secret.value), secret.mode); err != nil { if err := os.WriteFile(fp, []byte(secret.value), secret.mode); err != nil {
@ -463,7 +466,7 @@ func (app *appContext) loadSopsFile(s *secret) (*secretFile, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("cannot parse ini of '%s': %w", s.SopsFile, err) return nil, fmt.Errorf("cannot parse ini of '%s': %w", s.SopsFile, err)
} }
// TODO: we do not acctually check the contents of the ini here... // TODO: we do not actually check the contents of the ini here...
} }
return &secretFile{ return &secretFile{
@ -491,7 +494,7 @@ func (app *appContext) validateSopsFile(s *secret, file *secretFile) error {
func validateMode(mode string) (os.FileMode, error) { func validateMode(mode string) (os.FileMode, error) {
parsed, err := strconv.ParseUint(mode, 8, 16) parsed, err := strconv.ParseUint(mode, 8, 16)
if err != nil { if err != nil {
return 0, fmt.Errorf("Invalid number in mode: %d: %w", mode, err) return 0, fmt.Errorf("invalid number in mode: %s: %w", mode, err)
} }
return os.FileMode(parsed), nil return os.FileMode(parsed), nil
} }
@ -499,11 +502,11 @@ func validateMode(mode string) (os.FileMode, error) {
func validateOwner(owner string) (int, error) { func validateOwner(owner string) (int, error) {
lookedUp, err := user.Lookup(owner) lookedUp, err := user.Lookup(owner)
if err != nil { if err != nil {
return 0, fmt.Errorf("Failed to lookup user '%s': %w", owner, err) return 0, fmt.Errorf("failed to lookup user '%s': %w", owner, err)
} }
ownerNr, err := strconv.ParseUint(lookedUp.Uid, 10, 64) ownerNr, err := strconv.ParseUint(lookedUp.Uid, 10, 64)
if err != nil { if err != nil {
return 0, fmt.Errorf("Cannot parse uid %s: %w", lookedUp.Uid, err) return 0, fmt.Errorf("cannot parse uid %s: %w", lookedUp.Uid, err)
} }
return int(ownerNr), nil return int(ownerNr), nil
} }
@ -511,11 +514,11 @@ func validateOwner(owner string) (int, error) {
func validateGroup(group string) (int, error) { func validateGroup(group string) (int, error) {
lookedUp, err := user.LookupGroup(group) lookedUp, err := user.LookupGroup(group)
if err != nil { if err != nil {
return 0, fmt.Errorf("Failed to lookup group '%s': %w", group, err) return 0, fmt.Errorf("failed to lookup group '%s': %w", group, err)
} }
groupNr, err := strconv.ParseUint(lookedUp.Gid, 10, 64) groupNr, err := strconv.ParseUint(lookedUp.Gid, 10, 64)
if err != nil { if err != nil {
return 0, fmt.Errorf("Cannot parse gid %s: %w", lookedUp.Gid, err) return 0, fmt.Errorf("cannot parse gid %s: %w", lookedUp.Gid, err)
} }
return int(groupNr), nil return int(groupNr), nil
} }
@ -531,39 +534,24 @@ func (app *appContext) validateSecret(secret *secret) error {
secret.owner = 0 secret.owner = 0
secret.group = 0 secret.group = 0
} else if app.checkMode == Off || app.ignorePasswd { } else if app.checkMode == Off || app.ignorePasswd {
// we only access to the user/group during deployment if secret.Owner == nil {
secret.owner = secret.UID
} else {
owner, err := validateOwner(*secret.Owner) owner, err := validateOwner(*secret.Owner)
if err != nil { if err != nil {
return err return err
} }
secret.owner = owner secret.owner = owner
if secret.Owner == nil {
secret.owner = secret.UID
} else {
owner, err := user.Lookup(*secret.Owner)
if err != nil {
return fmt.Errorf("failed to lookup user '%s': %w", *secret.Owner, err)
}
uid, err := strconv.ParseUint(owner.Uid, 10, 64)
if err != nil {
return fmt.Errorf("cannot parse uid %s: %w", owner.Uid, err)
}
secret.owner = int(uid)
} }
if secret.Group == nil { if secret.Group == nil {
secret.group = secret.GID secret.group = secret.GID
} else { } else {
group, err := user.LookupGroup(*secret.Group) group, err := validateGroup(*secret.Group)
if err != nil { if err != nil {
return fmt.Errorf("failed to lookup group '%s': %w", *secret.Group, err) return err
} }
gid, err := strconv.ParseUint(group.Gid, 10, 64) secret.group = group
if err != nil {
return fmt.Errorf("cannot parse gid %s: %w", group.Gid, err)
}
secret.group = int(gid)
} }
} }
@ -582,31 +570,26 @@ func (app *appContext) validateSecret(secret *secret) error {
return err return err
} }
app.secretFiles[secret.SopsFile] = *maybeFile app.secretFiles[secret.SopsFile] = *maybeFile
file = *maybeFile file = *maybeFile
} }
return app.validateSopsFile(secret, &file) return app.validateSopsFile(secret, &file)
} }
var PLACEHOLDER = regexp.MustCompile(`<SOPS:([a-f0-9]{64}):PLACEHOLDER>`) func renderTemplates(templates map[string]*template, secretByPlaceholder map[string]*secret) {
for _, template := range templates {
rendered := renderTemplate(&template.content, secretByPlaceholder)
template.value = []byte(rendered)
}
}
func renderTemplate(content *string, secrets []secret) ([]byte, error) { func renderTemplate(content *string, secretByPlaceholder map[string]*secret) string {
secretMap := make(map[string][]byte) rendered := *content
var err error = nil for placeholder, secret := range secretByPlaceholder {
replaced := PLACEHOLDER.ReplaceAllStringFunc(*content, func(match string) string { rendered = strings.ReplaceAll(rendered, placeholder, string(secret.value))
secretName := PLACEHOLDER.FindStringSubmatch(match)[1]
for _, secret := range secrets {
if secret.Name == secretName {
secretMap[secretName] = secret.value
return string(secret.value)
} }
} return rendered
return match
})
if err != nil {
return nil, err
}
return []byte{}, nil
} }
func (app *appContext) validateTemplate(template *template) error { func (app *appContext) validateTemplate(template *template) error {
@ -620,19 +603,26 @@ func (app *appContext) validateTemplate(template *template) error {
template.owner = 0 template.owner = 0
template.group = 0 template.group = 0
} else if app.checkMode == Off || app.ignorePasswd { } else if app.checkMode == Off || app.ignorePasswd {
// we only access to the user/group during deployment if template.Owner == nil {
owner, err := validateOwner(template.Owner) template.owner = template.UID
} else {
owner, err := validateOwner(*template.Owner)
if err != nil { if err != nil {
return err return err
} }
template.owner = owner template.owner = owner
}
group, err := validateGroup(template.Group) if template.Group == nil {
template.group = template.GID
} else {
group, err := validateGroup(*template.Group)
if err != nil { if err != nil {
return err return err
} }
template.group = group template.group = group
} }
}
var templateText string var templateText string
if template.Content != "" { if template.Content != "" {
@ -640,17 +630,14 @@ func (app *appContext) validateTemplate(template *template) error {
} else if template.File != "" { } else if template.File != "" {
templateBytes, err := os.ReadFile(template.File) templateBytes, err := os.ReadFile(template.File)
if err != nil { if err != nil {
return fmt.Errorf("Cannot read %s: %w", template.File, err) return fmt.Errorf("cannot read %s: %w", template.File, err)
} }
templateText = string(templateBytes) templateText = string(templateBytes)
} else { } else {
return fmt.Errorf("Neither content nor file was specified for template %s", template.Name) return fmt.Errorf("neither content nor file was specified for template %s", template.Name)
} }
rendered, err := renderTemplate(&templateText, app.manifest.Secrets)
if err != nil { template.content = templateText
return fmt.Errorf("Failed to render template %s: %w", template.Name, err)
}
template.value = rendered
return nil return nil
} }
@ -668,15 +655,22 @@ func (app *appContext) validateManifest() error {
} }
} }
for _, secret := range m.Secrets { for i := range m.Secrets {
if err := app.validateSecret(&secret); err != nil { secret := &m.Secrets[i]
if err := app.validateSecret(secret); err != nil {
return err return err
} }
}
// The Nix module only defines placeholders for secrets if there are
// templates.
if len(m.Templates) > 0 { if len(m.Templates) > 0 {
placeholder := m.PlaceholderBySecretName[secret.Name]
app.secretByPlaceholder[placeholder] = secret
} }
}
for _, template := range m.Templates { for _, template := range m.Templates {
if err := app.validateTemplate(&template); err != nil { if err := app.validateTemplate(template); err != nil {
return err return err
} }
} }
@ -1060,20 +1054,21 @@ func replaceRuntimeDir(path, rundir string) (ret string) {
return return
} }
func writeTemplates(targetDir string, templates []template, keysGid int, userMode bool) error { func writeTemplates(targetDir string, templates map[string]*template, keysGID int, userMode bool) error {
for _, template := range templates { for _, template := range templates {
fp := filepath.Join(targetDir, template.Name) fp := filepath.Join(targetDir, template.Name)
createParentDirs(targetDir, template.Name, keysGid, userMode) if err := createParentDirs(targetDir, template.Name, keysGID, userMode); err != nil {
return err
}
if err := os.WriteFile(fp, []byte(template.value), template.mode); err != nil { if err := os.WriteFile(fp, []byte(template.value), template.mode); err != nil {
return fmt.Errorf("Cannot write %s: %w", fp, err) return fmt.Errorf("cannot write %s: %w", fp, err)
} }
if !userMode { if !userMode {
if err := os.Chown(fp, template.owner, template.group); err != nil { if err := os.Chown(fp, template.owner, template.group); err != nil {
return fmt.Errorf("Cannot change owner/group of '%s' to %d/%d: %w", fp, secret.owner, secret.group, err) return fmt.Errorf("cannot change owner/group of '%s' to %d/%d: %w", fp, template.owner, template.group, err)
} } }
}
} }
return nil return nil
} }
@ -1110,6 +1105,7 @@ func installSecrets(args []string) error {
checkMode: opts.checkMode, checkMode: opts.checkMode,
ignorePasswd: opts.ignorePasswd, ignorePasswd: opts.ignorePasswd,
secretFiles: make(map[string]secretFile), secretFiles: make(map[string]secretFile),
secretByPlaceholder: make(map[string]*secret),
} }
if err = app.validateManifest(); err != nil { if err = app.validateManifest(); err != nil {
@ -1183,10 +1179,13 @@ func installSecrets(args []string) error {
} }
} }
if err = decryptSecrets(manifest.Secrets); err != nil { if err := decryptSecrets(manifest.Secrets); err != nil {
return err return err
} }
// Now that the secrets are decrypted, we can render the templates.
renderTemplates(manifest.Templates, app.secretByPlaceholder)
secretDir, err := prepareSecretsDir(manifest.SecretsMountPoint, manifest.SymlinkPath, keysGID, manifest.UserMode) secretDir, err := prepareSecretsDir(manifest.SecretsMountPoint, manifest.SymlinkPath, keysGID, manifest.UserMode)
if err != nil { if err != nil {
return fmt.Errorf("failed to prepare new secrets directory: %w", err) return fmt.Errorf("failed to prepare new secrets directory: %w", err)
@ -1195,8 +1194,8 @@ func installSecrets(args []string) error {
return fmt.Errorf("cannot write secrets: %w", err) return fmt.Errorf("cannot write secrets: %w", err)
} }
if err := writeTemplates(path.Join(*secretDir, "rendered"), manifest.Templates, keysGid, manifest.UserMode); err != nil { if err := writeTemplates(path.Join(*secretDir, "rendered"), manifest.Templates, keysGID, manifest.UserMode); err != nil {
return fmt.Errorf("Cannot render templates: %w", err) return fmt.Errorf("cannot render templates: %w", err)
} }
if !manifest.UserMode { if !manifest.UserMode {

View file

@ -241,7 +241,7 @@ in {
testScript = '' testScript = ''
def assertEqual(exp: str, act: str) -> None: def assertEqual(exp: str, act: str) -> None:
if exp != act: if exp != act:
raise Exception(f"'{exp}' != '{act}'") raise Exception(f"{exp!r} != {act!r}")
start_all() start_all()
@ -260,7 +260,7 @@ in {
templates = testers.runNixOSTest { templates = testers.runNixOSTest {
name = "sops-templates"; name = "sops-templates";
nodes.machine = { config, lib, ... }: { nodes.machine = { config, ... }: {
imports = [ ../../modules/sops ]; imports = [ ../../modules/sops ];
sops = { sops = {
age.keyFile = "/run/age-keys.txt"; age.keyFile = "/run/age-keys.txt";
@ -296,32 +296,37 @@ in {
}; };
testScript = '' testScript = ''
start_all() def assertEqual(exp: str, act: str) -> None:
machine.succeed("[ $(stat -c%U /run/secrets-rendered/test_template) = 'someuser' ]") if exp != act:
machine.succeed("[ $(stat -c%G /run/secrets-rendered/test_template) = 'somegroup' ]") raise Exception(f"{exp!r} != {act!r}")
machine.succeed("[ $(stat -c%U /run/secrets-rendered/test_default) = 'root' ]")
machine.succeed("[ $(stat -c%G /run/secrets-rendered/test_default) = 'root' ]")
expected = """
start_all()
machine.succeed("[ $(stat -c%U /run/secrets/rendered/test_template) = 'someuser' ]")
machine.succeed("[ $(stat -c%G /run/secrets/rendered/test_template) = 'somegroup' ]")
machine.succeed("[ $(stat -c%U /run/secrets/rendered/test_default) = 'root' ]")
machine.succeed("[ $(stat -c%G /run/secrets/rendered/test_default) = 'root' ]")
expected = """\
This line is not modified. This line is not modified.
The next value will be replaced by test_value The next value will be replaced by test_value
This line is also not modified. This line is also not modified.
""" """
rendered = machine.succeed("cat /run/secrets-rendered/test_template") rendered = machine.succeed("cat /run/secrets/rendered/test_template")
expected_default = """ expected_default = """\
Test value: test_value Test value: test_value
""" """
rendered_default = machine.succeed("cat /run/secrets-rendered/test_default") rendered_default = machine.succeed("cat /run/secrets/rendered/test_default")
if rendered.strip() != expected.strip() or rendered_default.strip() != expected_default.strip(): assertEqual(expected, rendered)
raise Exception("Template is not rendered correctly") assertEqual(expected_default, rendered_default)
''; '';
}; };
restart-and-reload = testers.runNixOSTest { restart-and-reload = testers.runNixOSTest {
name = "sops-restart-and-reload"; name = "sops-restart-and-reload";
nodes.machine = { pkgs, lib, config, ... }: { nodes.machine = {
imports = [ ../../modules/sops ]; imports = [ ../../modules/sops ];
sops = { sops = {