├── .copr
└── Makefile
├── .github
├── mergify.yml
└── workflows
│ └── ci.yml
├── .gitignore
├── .gitlint
├── .hgignore
├── COPYING
├── MANIFEST.in
├── README.md
├── docs
├── configuration.md
└── release-process.md
├── examples
├── addc.json
├── addc_ou.json
├── ctdb.json
├── example1.json
└── minimal.json
├── extras
└── python-sambacc.spec
├── pyproject.toml
├── sambacc
├── __init__.py
├── _xattr.py
├── addc.py
├── commands
│ ├── __init__.py
│ ├── addc.py
│ ├── check.py
│ ├── cli.py
│ ├── common.py
│ ├── config.py
│ ├── ctdb.py
│ ├── dcmain.py
│ ├── dns.py
│ ├── initialize.py
│ ├── join.py
│ ├── main.py
│ ├── remotecontrol
│ │ ├── __init__.py
│ │ ├── main.py
│ │ └── server.py
│ ├── run.py
│ ├── skips.py
│ └── users.py
├── config.py
├── container_dns.py
├── ctdb.py
├── grpc
│ ├── __init__.py
│ ├── backend.py
│ ├── generated
│ │ ├── __init__.py
│ │ ├── control_pb2.py
│ │ ├── control_pb2.pyi
│ │ └── control_pb2_grpc.py
│ ├── protobufs
│ │ └── control.proto
│ └── server.py
├── inotify_waiter.py
├── jfile.py
├── join.py
├── leader.py
├── netcmd_loader.py
├── nsswitch_loader.py
├── opener.py
├── passdb_loader.py
├── passwd_loader.py
├── paths.py
├── permissions.py
├── rados_opener.py
├── samba_cmds.py
├── schema
│ ├── __init__.py
│ ├── conf-v0.schema.json
│ ├── conf-v0.schema.yaml
│ ├── conf_v0_schema.py
│ └── tool.py
├── simple_waiter.py
├── smbconf_api.py
├── smbconf_samba.py
├── textfile.py
├── typelets.py
└── url_opener.py
├── setup.cfg
├── tests
├── __init__.py
├── container
│ ├── Containerfile
│ └── build.sh
├── test_addc.py
├── test_commands_config.py
├── test_config.py
├── test_container_dns.py
├── test_ctdb.py
├── test_grpc_backend.py
├── test_grpc_server.py
├── test_inotify_waiter.py
├── test_jfile.py
├── test_join.py
├── test_main.py
├── test_netcmd_loader.py
├── test_passdb_loader.py
├── test_passwd_loader.py
├── test_paths.py
├── test_permissions.py
├── test_rados_opener.py
├── test_samba_cmds.py
├── test_simple_waiter.py
├── test_skips.py
├── test_smbconf_api.py
├── test_smbconf_samba.py
└── test_url_opener.py
└── tox.ini
/.copr/Makefile:
--------------------------------------------------------------------------------
1 |
2 |
3 | SELF=$(lastword $(MAKEFILE_LIST))
4 | ROOT_DIR=$(abspath $(dir $(SELF))/..)
5 | SKIP_DEPS=
6 |
7 | outdir:=/var/tmp/copr-tmp-outdir
8 | spec:=extras/python-sambacc.spec
9 |
10 | .PHONY: srpm
11 | srpm: sys_deps
12 | mkdir -p $(outdir)
13 | git fetch --tags
14 | SAMBACC_SRPM_ONLY=yes \
15 | SAMBACC_BUILD_DIR=$(ROOT_DIR) \
16 | SAMBACC_DIST_PREFIX=$(outdir)/.dist \
17 | SAMBACC_DISTNAME=copr \
18 | SAMBACC_BUILD_TASKS="task_py_build task_rpm_build" \
19 | ./tests/container/build.sh
20 | cp $(outdir)/.dist/copr/SRPMS/*.rpm $(outdir)
21 |
22 |
23 | .PHONY: sys_deps
24 | sys_deps:
25 | ifeq ($(SKIP_DEPS),yes)
26 | @echo "Skipping sys deps"
27 | else
28 | dnf install -y python3-pip git
29 | endif
30 |
--------------------------------------------------------------------------------
/.github/mergify.yml:
--------------------------------------------------------------------------------
1 | ---
2 | # each test should be listed separately, do not use regular expressions:
3 | # https://docs.mergify.io/conditions.html#validating-all-status-check
4 | # TODO: Use mergify's recently added 'shared configuration support'
5 | # to dedup some of the check-x=y repetition in the future.
6 | queue_rules:
7 | - name: default
8 | conditions:
9 | - check-success=check-commits
10 | - check-success=test (fedora-latest)
11 | - check-success=test (fedora-previous)
12 | - check-success=test (centos-stream9)
13 | - check-success=dpulls
14 | merge_method: rebase
15 | update_method: rebase
16 |
17 |
18 | pull_request_rules:
19 | # Clearing approvals after content changes
20 | - name: Remove outdated approvals
21 | conditions:
22 | - base=master
23 | actions:
24 | dismiss_reviews:
25 | approved: true
26 | changes_requested: false
27 | # Perform automatic merge on conditions
28 | - name: Automatic merge on approval
29 | conditions:
30 | - check-success=check-commits
31 | - check-success=test (fedora-latest)
32 | - check-success=test (fedora-previous)
33 | - check-success=test (centos-stream9)
34 | - check-success=dpulls
35 | - "-draft"
36 | # Contributors should set the 'do-not-merge' label if they don't want
37 | # the PR to be (auto)merged for some reason.
38 | - "label!=do-not-merge"
39 | # A reviewer should set a label starting with 'review-in-progress' (and
40 | # suffixed by their username) in order to indicate a detailed review has
41 | # been started and not completed. This will hold the PR until the
42 | # label has been removed.
43 | - "-label~=^review-in-progress"
44 | - "base=master"
45 | # Even if there are 2 or more approvals we won't automerge if there are
46 | # any changes requested.
47 | - "#changes-requested-reviews-by=0"
48 | - or:
49 | # Any contributor's PR can be automerged with 2 (or more) reviews.
50 | - "#approved-reviews-by>=2"
51 | # A maintainer's contribution that has already aged long enough to
52 | # earn the "priority-review" label can be merged immediately.
53 | # The label can also be applied manually in case of an important
54 | # bugfix, etc.
55 | - and:
56 | - "label=priority-review"
57 | - "author=@maintainers"
58 | - "#approved-reviews-by>=1"
59 | actions:
60 | queue: {}
61 | dismiss_reviews: {}
62 | # Conflict resolution prompt
63 | - name: Ask contributor to resolve a conflict
64 | conditions:
65 | - conflict
66 | actions:
67 | comment:
68 | message: "This pull request now has conflicts with the target branch.
69 | Please resolve these conflicts and force push the updated branch."
70 | # Label PRs that have been sitting there unchanged, aging like a fine wine
71 | #
72 | # NOTE: the updated-at "counter" resets every time the PR is changed so
73 | # reacting to a reviewer's feedback and fixing a typo (for example) will
74 | # reset the counter. Thus we now apply a label once we hit the 15 day window
75 | # so that we know that PR had, at some time, sat unchanged for that long.
76 | - name: Label aged PRs
77 | conditions:
78 | - "updated-at<15 days ago"
79 | - "-draft"
80 | - "-closed"
81 | - "-merged"
82 | actions:
83 | label:
84 | add:
85 | - "priority-review"
86 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | ---
2 | name: CI
3 |
4 | on:
5 | push:
6 | branches: [master]
7 | pull_request:
8 | branches: [master]
9 | schedule:
10 | - cron: 1 1 * * *
11 |
12 | jobs:
13 | fedora-versions:
14 | runs-on: ubuntu-latest
15 | steps:
16 | - id: fedora-versions
17 | run: |
18 | curl -s -L https://fedoraproject.org/releases.json -o fedora-releases.json
19 | LATEST=$(jq -r '[.[]|select(.variant == "Container" and .subvariant == "Container_Base" and .arch == "x86_64")][0]|.version' fedora-releases.json)
20 | PREVIOUS=$((LATEST - 1))
21 |
22 | echo "latest=$LATEST" >> $GITHUB_OUTPUT
23 | echo "previous=$PREVIOUS" >> $GITHUB_OUTPUT
24 | outputs:
25 | latest: ${{ steps.fedora-versions.outputs.latest }}
26 | previous: ${{ steps.fedora-versions.outputs.previous }}
27 | check-commits:
28 | runs-on: ubuntu-latest
29 | if: github.event_name == 'pull_request'
30 | steps:
31 | - uses: actions/checkout@v4
32 | with:
33 | fetch-depth: 0
34 | ref: ${{ github.event.pull_request.head.sha }}
35 | - uses: actions/setup-python@v4
36 | - name: Install tox
37 | run: python -m pip install tox
38 | - name: Run gitlint
39 | run: tox -e gitlint
40 | test:
41 | needs: fedora-versions
42 | runs-on: ubuntu-latest
43 | strategy:
44 | fail-fast: false
45 | matrix:
46 | test_distro: ["fedora-previous", "fedora-latest", "centos-stream9"]
47 | include:
48 | - test_distro: "fedora-previous"
49 | base_image: "registry.fedoraproject.org/fedora:${{ needs.fedora-versions.outputs.previous }}"
50 | - test_distro: "fedora-latest"
51 | base_image: "registry.fedoraproject.org/fedora:${{ needs.fedora-versions.outputs.latest }}"
52 | - test_distro: "centos-stream9"
53 | base_image: "quay.io/centos/centos:stream9"
54 | steps:
55 | - uses: actions/checkout@v4
56 | with:
57 | fetch-depth: 0
58 | - name: Build test container
59 | run: docker build -t sambacc:ci-${{ matrix.test_distro }} --build-arg=SAMBACC_BASE_IMAGE=${{ matrix.base_image }} tests/container/ -f tests/container/Containerfile
60 | - name: Run test container
61 | run: docker run -v $PWD:/var/tmp/build/sambacc sambacc:ci-${{ matrix.test_distro }}
62 |
63 | push:
64 | needs: [test]
65 | runs-on: ubuntu-latest
66 | if: (github.event_name == 'push' || github.event_name == 'schedule') && github.repository == 'samba-in-kubernetes/sambacc'
67 | steps:
68 | - uses: actions/checkout@v4
69 | - name: log in to quay.io
70 | run: docker login -u "${{ secrets.QUAY_USER }}" -p "${{ secrets.QUAY_PASS }}" quay.io
71 | - name: build container image
72 | run: docker build -t quay.io/samba.org/sambacc:latest tests/container -f tests/container/Containerfile
73 | - name: publish container image
74 | run: docker push quay.io/samba.org/sambacc:latest
75 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .venv
2 | .tox
3 | .egg-info
4 | __pycache__
5 | htmlcov
6 | *.swp
7 | dist/
8 | build/
9 | .mypy_cache
10 | sambacc/_version.py
11 |
--------------------------------------------------------------------------------
/.gitlint:
--------------------------------------------------------------------------------
1 | # Edit this file as you like.
2 | #
3 | # All these sections are optional. Each section with the exception of [general] represents
4 | # one rule and each key in it is an option for that specific rule.
5 | #
6 | # Rules and sections can be referenced by their full name or by id. For example
7 | # section "[body-max-line-length]" could also be written as "[B1]". Full section names are
8 | # used in here for clarity.
9 | #
10 | [general]
11 | # Ignore certain rules, this example uses both full name and id
12 | # ignore=title-trailing-punctuation, T3
13 |
14 | # verbosity should be a value between 1 and 3, the commandline -v flags take precedence over this
15 | verbosity=3
16 |
17 | # By default gitlint will ignore merge, revert, fixup and squash commits.
18 | ignore-merge-commits=true
19 | # ignore-revert-commits=true
20 | # ignore-fixup-commits=true
21 | # ignore-squash-commits=true
22 |
23 | # Ignore any data send to gitlint via stdin
24 | # ignore-stdin=true
25 |
26 | # Fetch additional meta-data from the local repository when manually passing a
27 | # commit message to gitlint via stdin or --commit-msg. Disabled by default.
28 | # staged=true
29 |
30 | # Enable debug mode (prints more output). Disabled by default.
31 | # debug=true
32 |
33 | # Enable search regex and remove warning message.
34 | regex-style-search=true
35 |
36 | # Enable community contributed rules
37 | # See http://jorisroovers.github.io/gitlint/contrib_rules for details
38 | contrib=contrib-body-requires-signed-off-by
39 |
40 | # Set the extra-path where gitlint will search for user defined rules
41 | # See http://jorisroovers.github.io/gitlint/user_defined_rules for details
42 | # extra-path=examples/
43 |
44 | # This is an example of how to configure the "title-max-length" rule and
45 | # set the line-length it enforces to 80
46 | [title-max-length]
47 | line-length=72
48 |
49 | # Conversely, you can also enforce minimal length of a title with the
50 | # "title-min-length" rule:
51 | # [title-min-length]
52 | # min-length=5
53 |
54 | [title-must-not-contain-word]
55 | # Comma-separated list of words that should not occur in the title. Matching is case
56 | # insensitive. It's fine if the keyword occurs as part of a larger word (so "WIPING"
57 | # will not cause a violation, but "WIP: my title" will.
58 | words=wip,WIP
59 |
60 | [title-match-regex]
61 | # python-style regex that the commit-msg title must match
62 | # Note that the regex can contradict with other rules if not used correctly
63 | # (e.g. title-must-not-contain-word).
64 | regex=^.{2,32}: .*
65 |
66 | # [body-max-line-length]
67 | # line-length=72
68 |
69 | # [body-min-length]
70 | # min-length=5
71 |
72 | # [body-is-missing]
73 | # Whether to ignore this rule on merge commits (which typically only have a title)
74 | # default = True
75 | # ignore-merge-commits=false
76 |
77 | # [body-changed-file-mention]
78 | # List of files that need to be explicitly mentioned in the body when they are changed
79 | # This is useful for when developers often erroneously edit certain files or git submodules.
80 | # By specifying this rule, developers can only change the file when they explicitly reference
81 | # it in the commit message.
82 | # files=gitlint/rules.py,README.md
83 |
84 | # [body-match-regex]
85 | # python-style regex that the commit-msg body must match.
86 | # E.g. body must end in My-Commit-Tag: foo
87 | # regex=My-Commit-Tag: foo$
88 |
89 | # [author-valid-email]
90 | # python-style regex that the commit author email address must match.
91 | # For example, use the following regex if you only want to allow email addresses from foo.com
92 | # regex=[^@]+@foo.com
93 |
94 | # [ignore-by-title]
95 | # Ignore certain rules for commits of which the title matches a regex
96 | # E.g. Match commit titles that start with "Release"
97 | # regex=^Release(.*)
98 |
99 | # Ignore certain rules, you can reference them by their id or by their full name
100 | # Use 'all' to ignore all rules
101 | # ignore=T1,body-min-length
102 |
103 | # [ignore-by-body]
104 | # Ignore certain rules for commits of which the body has a line that matches a regex
105 | # E.g. Match bodies that have a line that that contain "release"
106 | # regex=(.*)release(.*)
107 | #
108 | # Ignore certain rules, you can reference them by their id or by their full name
109 | # Use 'all' to ignore all rules
110 | # ignore=T1,body-min-length
111 |
112 | [ignore-body-lines]
113 | # Ignore certain lines in a commit body that match a regex.
114 | # E.g. Ignore all lines that start with 'Co-Authored-By'
115 | # regex=^Co-Authored-By
116 |
117 | # ignore lines that are "footnotes", that start like `[1]: ` or `[2]: ` and so on
118 | # this will make it easy to put long urls in commit messages without
119 | # triggering gitlint body rules
120 | regex=^\[[0-9]+\]:? +
121 |
122 | # This is a contrib rule - a community contributed rule. These are disabled by default.
123 | # You need to explicitly enable them one-by-one by adding them to the "contrib" option
124 | # under [general] section above.
125 | # [contrib-title-conventional-commits]
126 | # Specify allowed commit types. For details see: https://www.conventionalcommits.org/
127 | # types = bugfix,user-story,epic
128 |
--------------------------------------------------------------------------------
/.hgignore:
--------------------------------------------------------------------------------
1 | .venv
2 | .tox
3 | .egg-info
4 | __pycache__
5 | htmlcov
6 | \.coverage$
7 | \.pytest_cache
8 | \.swp$
9 | ^dist/
10 | ^build/
11 | .mypy_cache
12 | sambacc/_version.py
13 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | # Include example files
2 | recursive-include examples *
3 | recursive-include sambacc/schema *.json *.yaml
4 |
--------------------------------------------------------------------------------
/docs/release-process.md:
--------------------------------------------------------------------------------
1 | # sambacc Release Process
2 |
3 | ## Preparation
4 |
5 | Currently there is no dedicated branch for releases. sambacc is simple enough,
6 | has few dependencies, and we're not planning on doing backports. Therefore
7 | we apply release tags to the master branch.
8 |
9 | ```
10 | git checkout master
11 | git pull --ff-only
12 | git tag -a -m 'Release v0.3' v0.3
13 | ```
14 |
15 | This creates an annotated tag. Release tags must be annotated tags.
16 |
17 | Perform a final check that all supported OSes build. You can
18 | follow the commands below, which are based on the github workflows at the
19 | time this document was written:
20 |
21 | ```
22 | podman build --build-arg=SAMBACC_BASE_IMAGE=quay.io/centos/centos:stream9 -t sambacc:temp-centos9 tests/container/ -f tests/container/Containerfile
23 | podman build --build-arg=SAMBACC_BASE_IMAGE=registry.fedoraproject.org/fedora:37 -t sambacc:temp-fc37 tests/container/ -f tests/container/Containerfile
24 | podman build --build-arg=SAMBACC_BASE_IMAGE=registry.fedoraproject.org/fedora:38 -t sambacc:temp-fc38 tests/container/ -f tests/container/Containerfile
25 |
26 | # name the last part after the release version
27 | mybuild=$PWD/_builds/v03
28 | mkdir -p $mybuild
29 | # perform a combined test & build, that stores build artifacts under $mybuild/$SAMBACC_DISTNAME
30 | podman run -v $PWD:/var/tmp/build/sambacc -v $mybuild:/srv/dist -e SAMBACC_DISTNAME=centos9 sambacc:temp-centos9
31 | podman run -v $PWD:/var/tmp/build/sambacc -v $mybuild:/srv/dist -e SAMBACC_DISTNAME=fc37 sambacc:temp-fc37
32 | podman run -v $PWD:/var/tmp/build/sambacc -v $mybuild:/srv/dist -e SAMBACC_DISTNAME=fc38 sambacc:temp-fc38
33 |
34 | # view build results
35 | ls -lR $mybuild
36 | ```
37 |
38 | Modify the set of base OSes to match what is supported by the release. Check
39 | that the logs show that tag version was correctly picked up by the build.
40 | The python and rpm packages should indicate the new release version and not
41 | include an "unreleased git version".
42 |
43 | For at least one build, select a set of files that includes the source tarball,
44 | the Python Wheel (.whl file), and a source RPM. Create or alter an existing
45 | sha512sums file containing the sha512 hashes of these files.
46 |
47 |
48 | ## GitHub Release
49 |
50 | When you are satisfied that the tagged version is suitable for release, you
51 | can push the tag to the public repo:
52 | ```
53 | git push --follow-tags
54 | ```
55 |
56 | Manually trigger a COPR build. Confirm that new COPR build contains the correct
57 | version number and doesn't include an "unreleased git version".
58 | You will need to have a fedora account and the ability to trigger builds
59 | for `phlogistonjohn/sambacc`.
60 |
61 | Draft a new set of release notes. Select the recently pushed tag. Start with
62 | the auto-generated release notes from github (activate the `Generate release
63 | notes` button/link). Add an introductory section (see previous notes for an
64 | example). Add a "Highlights" section if there are any notable features or fixes
65 | in the release. The Highlights section can be skipped if the content of the
66 | release is unremarkable (e.g. few changes occurred since the previous release).
67 |
68 | Attach the source tarball, the Python Wheel, and one SRPM from the earlier
69 | build(s), along with the sha512sums file to the release.
70 |
71 | Perform a final round of reviews, as needed, for the release notes and then
72 | publish the release.
73 |
74 |
75 | ## PyPI
76 |
77 | There is a [sambacc repository on PyPI](https://pypi.org/project/sambacc/).
78 | This exists mainly to reserve the sambacc name, however we desire to keep it up
79 | to date too. You will need to have a PyPI account and access to the sambacc
80 | repo.
81 |
82 | Log into PyPI web UI. (Re)Generate a pypi login token for sambacc.
83 | Ensure `twine` is installed:
84 | ```
85 | python3 -m pip install --upgrade twine
86 | ```
87 |
88 | Create a directory to store the python build artifacts:
89 | ```
90 | rm -rf _build/pypi
91 | mkdir -p _build/pypi
92 | cp sambacc-0.3.tar.gz sambacc-0.3-py3-none-any.whl _build/pypi
93 | ```
94 | Upload the files to PyPI creating a new release:
95 | ```
96 | python3 -m twine upload _build/pypi/*
97 | # Supply a username of `__token__` and the password will be the value
98 | of the token you acquiried above.
99 | ```
100 |
101 | A new release like `https://pypi.org/project/sambacc/0.3/` should have become
102 | available.
103 |
--------------------------------------------------------------------------------
/examples/addc.json:
--------------------------------------------------------------------------------
1 | {
2 | "samba-container-config": "v0",
3 | "configs": {
4 | "demo": {
5 | "instance_features": ["addc"],
6 | "domain_settings": "sink",
7 | "instance_name": "dc1"
8 | }
9 | },
10 | "domain_settings": {
11 | "sink": {
12 | "realm": "DOMAIN1.SINK.TEST",
13 | "short_domain": "DOMAIN1",
14 | "admin_password": "Passw0rd"
15 | }
16 | },
17 | "domain_groups": {
18 | "sink": [
19 | {"name": "supervisors"},
20 | {"name": "employees"},
21 | {"name": "characters"},
22 | {"name": "bulk"}
23 | ]
24 | },
25 | "domain_users": {
26 | "sink": [
27 | {
28 | "name": "bwayne",
29 | "password": "1115Rose.",
30 | "given_name": "Bruce",
31 | "surname": "Wayne",
32 | "member_of": ["supervisors", "characters", "employees"]
33 | },
34 | {
35 | "name": "ckent",
36 | "password": "1115Rose.",
37 | "given_name": "Clark",
38 | "surname": "Kent",
39 | "member_of": ["characters", "employees"]
40 | },
41 | {
42 | "name": "bbanner",
43 | "password": "1115Rose.",
44 | "given_name": "Bruce",
45 | "surname": "Banner",
46 | "member_of": ["characters", "employees"]
47 | },
48 | {
49 | "name": "pparker",
50 | "password": "1115Rose.",
51 | "given_name": "Peter",
52 | "surname": "Parker",
53 | "member_of": ["characters", "employees"]
54 | },
55 | {
56 | "name": "user0",
57 | "password": "1115Rose.",
58 | "given_name": "George0",
59 | "surname": "Hue-Sir",
60 | "member_of": ["bulk"]
61 | },
62 | {
63 | "name": "user1",
64 | "password": "1115Rose.",
65 | "given_name": "George1",
66 | "surname": "Hue-Sir",
67 | "member_of": ["bulk"]
68 | },
69 | {
70 | "name": "user2",
71 | "password": "1115Rose.",
72 | "given_name": "George2",
73 | "surname": "Hue-Sir",
74 | "member_of": ["bulk"]
75 | },
76 | {
77 | "name": "user3",
78 | "password": "1115Rose.",
79 | "given_name": "George3",
80 | "surname": "Hue-Sir",
81 | "member_of": ["bulk"]
82 | },
83 | {
84 | "name": "user4",
85 | "password": "1115Rose.",
86 | "given_name": "George4",
87 | "surname": "Hue-Sir",
88 | "member_of": ["bulk"]
89 | },
90 | {
91 | "name": "user5",
92 | "password": "1115Rose.",
93 | "given_name": "George5",
94 | "surname": "Hue-Sir",
95 | "member_of": ["bulk"]
96 | },
97 | {
98 | "name": "user6",
99 | "password": "1115Rose.",
100 | "given_name": "George6",
101 | "surname": "Hue-Sir",
102 | "member_of": ["bulk"]
103 | },
104 | {
105 | "name": "user7",
106 | "password": "1115Rose.",
107 | "given_name": "George7",
108 | "surname": "Hue-Sir",
109 | "member_of": ["bulk"]
110 | },
111 | {
112 | "name": "user8",
113 | "password": "1115Rose.",
114 | "given_name": "George8",
115 | "surname": "Hue-Sir",
116 | "member_of": ["bulk"]
117 | },
118 | {
119 | "name": "user9",
120 | "password": "1115Rose.",
121 | "given_name": "George9",
122 | "surname": "Hue-Sir",
123 | "member_of": ["bulk"]
124 | }
125 | ]
126 | }
127 | }
128 |
--------------------------------------------------------------------------------
/examples/addc_ou.json:
--------------------------------------------------------------------------------
1 | {
2 | "samba-container-config": "v0",
3 | "configs": {
4 | "demo": {
5 | "instance_features": ["addc"],
6 | "domain_settings": "sink",
7 | "instance_name": "dc1"
8 | }
9 | },
10 | "domain_settings": {
11 | "sink": {
12 | "realm": "DOMAIN1.SINK.TEST",
13 | "short_domain": "DOMAIN1",
14 | "admin_password": "Passw0rd"
15 | }
16 | },
17 | "organizational_units": {
18 | "sink": [
19 | {"name": "employees"}
20 | ]
21 | },
22 | "domain_groups": {
23 | "sink": [
24 | {"name": "supervisors"},
25 | {
26 | "name": "employees",
27 | "ou": "employees"
28 | },
29 | {"name": "characters"},
30 | {"name": "bulk"}
31 | ]
32 | },
33 | "domain_users": {
34 | "sink": [
35 | {
36 | "name": "bwayne",
37 | "password": "1115Rose.",
38 | "given_name": "Bruce",
39 | "surname": "Wayne",
40 | "member_of": ["supervisors", "characters", "employees"],
41 | "ou": "employees"
42 | },
43 | {
44 | "name": "ckent",
45 | "password": "1115Rose.",
46 | "given_name": "Clark",
47 | "surname": "Kent",
48 | "member_of": ["characters", "employees"],
49 | "ou": "employees"
50 | },
51 | {
52 | "name": "bbanner",
53 | "password": "1115Rose.",
54 | "given_name": "Bruce",
55 | "surname": "Banner",
56 | "member_of": ["characters", "employees"],
57 | "ou": "employees"
58 | },
59 | {
60 | "name": "pparker",
61 | "password": "1115Rose.",
62 | "given_name": "Peter",
63 | "surname": "Parker",
64 | "member_of": ["characters", "employees"],
65 | "ou": "employees"
66 | },
67 | {
68 | "name": "user0",
69 | "password": "1115Rose.",
70 | "given_name": "George0",
71 | "surname": "Hue-Sir",
72 | "member_of": ["bulk"]
73 | },
74 | {
75 | "name": "user1",
76 | "password": "1115Rose.",
77 | "given_name": "George1",
78 | "surname": "Hue-Sir",
79 | "member_of": ["bulk"]
80 | },
81 | {
82 | "name": "user2",
83 | "password": "1115Rose.",
84 | "given_name": "George2",
85 | "surname": "Hue-Sir",
86 | "member_of": ["bulk"]
87 | },
88 | {
89 | "name": "user3",
90 | "password": "1115Rose.",
91 | "given_name": "George3",
92 | "surname": "Hue-Sir",
93 | "member_of": ["bulk"]
94 | },
95 | {
96 | "name": "user4",
97 | "password": "1115Rose.",
98 | "given_name": "George4",
99 | "surname": "Hue-Sir",
100 | "member_of": ["bulk"]
101 | },
102 | {
103 | "name": "user5",
104 | "password": "1115Rose.",
105 | "given_name": "George5",
106 | "surname": "Hue-Sir",
107 | "member_of": ["bulk"]
108 | },
109 | {
110 | "name": "user6",
111 | "password": "1115Rose.",
112 | "given_name": "George6",
113 | "surname": "Hue-Sir",
114 | "member_of": ["bulk"]
115 | },
116 | {
117 | "name": "user7",
118 | "password": "1115Rose.",
119 | "given_name": "George7",
120 | "surname": "Hue-Sir",
121 | "member_of": ["bulk"]
122 | },
123 | {
124 | "name": "user8",
125 | "password": "1115Rose.",
126 | "given_name": "George8",
127 | "surname": "Hue-Sir",
128 | "member_of": ["bulk"]
129 | },
130 | {
131 | "name": "user9",
132 | "password": "1115Rose.",
133 | "given_name": "George9",
134 | "surname": "Hue-Sir",
135 | "member_of": ["bulk"]
136 | }
137 | ]
138 | }
139 | }
140 |
--------------------------------------------------------------------------------
/examples/ctdb.json:
--------------------------------------------------------------------------------
1 | {
2 | "samba-container-config": "v0",
3 | "configs": {
4 | "demo": {
5 | "shares": [
6 | "share"
7 | ],
8 | "globals": [
9 | "default"
10 | ],
11 | "instance_features": ["ctdb"],
12 | "instance_name": "SAMBA"
13 | }
14 | },
15 | "shares": {
16 | "share": {
17 | "options": {
18 | "path": "/share",
19 | "read only": "no",
20 | "valid users": "sambauser, otheruser"
21 | }
22 | }
23 | },
24 | "globals": {
25 | "default": {
26 | "options": {
27 | "security": "user",
28 | "server min protocol": "SMB2",
29 | "load printers": "no",
30 | "printing": "bsd",
31 | "printcap name": "/dev/null",
32 | "disable spoolss": "yes",
33 | "guest ok": "no"
34 | }
35 | }
36 | },
37 | "users": {
38 | "all_entries": [
39 | {
40 | "name": "sambauser",
41 | "password": "samba"
42 | },
43 | {
44 | "name": "otheruser",
45 | "password": "insecure321"
46 | }
47 | ]
48 | },
49 | "_footer": 1
50 | }
51 |
--------------------------------------------------------------------------------
/examples/example1.json:
--------------------------------------------------------------------------------
1 | {
2 | "samba-container-config": "v0",
3 | "configs": {
4 | "example1": {
5 | "shares": [
6 | "demonstration",
7 | "examples"
8 | ],
9 | "globals": [
10 | "global0"
11 | ],
12 | "instance_name": "SERV1"
13 | }
14 | },
15 | "shares": {
16 | "demonstration": {
17 | "options": {
18 | "path": "/mnt/demo"
19 | }
20 | },
21 | "examples": {
22 | "options": {
23 | "path": "/mnt/examples"
24 | }
25 | }
26 | },
27 | "globals": {
28 | "global0": {
29 | "options": {
30 | "security": "user",
31 | "server min protocol": "SMB2",
32 | "load printers": "no",
33 | "printing": "bsd",
34 | "printcap name": "/dev/null",
35 | "disable spoolss": "yes",
36 | "guest ok": "no"
37 | }
38 | }
39 | },
40 | "users": {
41 | "all_entries": [
42 | {
43 | "name": "bob",
44 | "password": "notSoSafe"
45 | },
46 | {
47 | "name": "alice",
48 | "password": "123fakeStreet"
49 | },
50 | {
51 | "name": "carol",
52 | "nt_hash": "B784E584D34839235F6D88A5382C3821"
53 | }
54 | ]
55 | },
56 | "_footer": 1
57 | }
58 |
--------------------------------------------------------------------------------
/examples/minimal.json:
--------------------------------------------------------------------------------
1 | {
2 | "samba-container-config": "v0",
3 | "configs": {
4 | "demo": {
5 | "shares": [
6 | "share"
7 | ],
8 | "globals": [
9 | "default"
10 | ],
11 | "instance_name": "SAMBA"
12 | }
13 | },
14 | "shares": {
15 | "share": {
16 | "options": {
17 | "path": "/share",
18 | "valid users": "sambauser, otheruser"
19 | }
20 | }
21 | },
22 | "globals": {
23 | "default": {
24 | "options": {
25 | "security": "user",
26 | "server min protocol": "SMB2",
27 | "load printers": "no",
28 | "printing": "bsd",
29 | "printcap name": "/dev/null",
30 | "disable spoolss": "yes",
31 | "guest ok": "no"
32 | }
33 | }
34 | },
35 | "users": {
36 | "all_entries": [
37 | {
38 | "name": "sambauser",
39 | "password": "samba"
40 | },
41 | {
42 | "name": "otheruser",
43 | "password": "insecure321"
44 | }
45 | ]
46 | },
47 | "_footer": 1
48 | }
49 |
--------------------------------------------------------------------------------
/extras/python-sambacc.spec:
--------------------------------------------------------------------------------
1 | %global bname sambacc
2 | # set xversion to define the default version number
3 | %define xversion 0.1
4 | # set pversion for a customized python package version string
5 | %{?!pversion: %define pversion %{xversion}}
6 | # set rversion for a customized rpm version
7 | %{?!rversion: %define rversion %{xversion}}
8 |
9 |
10 | Name: python-%{bname}
11 | Version: %{rversion}
12 | Release: 1%{?dist}%{?vendordist}
13 | Summary: Samba Container Configurator
14 |
15 | License: GPLv3+
16 | URL: https://github.com/samba-in-kubernetes/sambacc
17 | # sambacc is not released yet so we're leaving off the url for now
18 | # once packaged and released we can update this field
19 | Source: %{bname}-%{pversion}.tar.gz
20 |
21 | BuildArch: noarch
22 | BuildRequires: python3-devel
23 | # we need python3-samba as a build dependency in order to run
24 | # the test suite
25 | BuildRequires: python3-samba
26 | # ditto for the net binary
27 | BuildRequires: /usr/bin/net
28 |
29 | %global _description %{expand:
30 | A Python library and set of CLI tools intended to act as a bridge between a container
31 | environment and Samba servers and utilities. It aims to consolidate, coordinate and
32 | automate all of the low level steps of setting up smbd, users, groups, and other
33 | supporting components.
34 | }
35 |
36 | %description %_description
37 |
38 | %package -n python3-%{bname}
39 | Summary: %{summary}
40 | # Distro requires that are technically optional for the lib
41 | Requires: python3-samba
42 | Requires: python3-pyxattr
43 | %if 0%{?fedora} >= 37 || 0%{?rhel} >= 9
44 | # Enable extras other than validation as the dependency needed
45 | # is too old on centos/rhel 9.
46 | Recommends: %{name}+toml
47 | Recommends: %{name}+yaml
48 | Recommends: %{name}+rados
49 | Recommends: %{name}+grpc
50 | %endif
51 | %if 0%{?fedora} >= 37
52 | Recommends: %{name}+validation
53 | %endif
54 |
55 | %description -n python3-%{bname} %_description
56 |
57 |
58 | %prep
59 | %autosetup -n %{bname}-%{pversion}
60 |
61 | %generate_buildrequires
62 | %pyproject_buildrequires -e py3-sys
63 |
64 |
65 | %build
66 | %pyproject_wheel
67 |
68 |
69 | %install
70 | %pyproject_install
71 | %pyproject_save_files %{bname}
72 |
73 |
74 | %check
75 | %tox -e py3-sys
76 |
77 |
78 | %files -n python3-%{bname} -f %{pyproject_files}
79 | %doc README.*
80 | %{_bindir}/samba-container
81 | %{_bindir}/samba-dc-container
82 | %{_bindir}/samba-remote-control
83 | %{_datadir}/%{bname}/examples/
84 |
85 |
86 | %pyproject_extras_subpkg -n python3-%{bname} validation
87 | %pyproject_extras_subpkg -n python3-%{bname} toml
88 | %pyproject_extras_subpkg -n python3-%{bname} yaml
89 | %pyproject_extras_subpkg -n python3-%{bname} rados
90 | %pyproject_extras_subpkg -n python3-%{bname} grpc
91 |
92 |
93 | %changelog
94 | %autochangelog
95 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [build-system]
2 | requires = ["setuptools>=42", "wheel", "setuptools_scm>=6.0"]
3 | build-backend = "setuptools.build_meta"
4 |
5 | [tool.setuptools_scm]
6 | fallback_version = "0.1"
7 | write_to = "sambacc/_version.py"
8 | write_to_template = """
9 | # coding: utf-8
10 | # Generated by setuptool_scm. Do not edit. Do not commit.
11 | version = "{version}"
12 | """
13 | # I wanted to save a 2nd var for the full hash, but it turns out there's no way
14 | # to grab the full hash from version control and save it to the file at this
15 | # time.
16 |
17 | [tool.black]
18 | line-length = 79
19 | quiet = true
20 |
21 | [tool.mypy]
22 | disallow_incomplete_defs = true
23 |
24 | [[tool.mypy.overrides]]
25 | module = "sambacc.*"
26 | disallow_untyped_defs = true
27 |
28 | [[tool.mypy.overrides]]
29 | module = "sambacc.commands.*"
30 | disallow_untyped_defs = false
31 |
32 | [[tool.mypy.overrides]]
33 | module = "sambacc.schema.*"
34 | disallow_untyped_defs = false
35 |
36 | [[tool.mypy.overrides]]
37 | module = "sambacc.grpc.generated.*"
38 | disallow_untyped_defs = false
39 | ignore_errors = true
40 |
--------------------------------------------------------------------------------
/sambacc/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/samba-in-kubernetes/sambacc/bf6b8c3de914a980056987862fc3a2db55b79c88/sambacc/__init__.py
--------------------------------------------------------------------------------
/sambacc/_xattr.py:
--------------------------------------------------------------------------------
1 | #
2 | # sambacc: a samba container configuration tool
3 | # Copyright (C) 2022 John Mulligan
4 | #
5 | # This program is free software: you can redistribute it and/or modify
6 | # it under the terms of the GNU General Public License as published by
7 | # the Free Software Foundation, either version 3 of the License, or
8 | # (at your option) any later version.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program. If not, see
17 | #
18 | """xattr shim module
19 |
20 | This module exists to insulate sambacc from the platform xattr module.
21 | Currently it only support pyxattr. This module can be imported without
22 | pyxattr (xattr) present. The functions will import the required module
23 | and raise an ImportError if xattr is not available.
24 |
25 | This shim also provides a typed functions for xattr management. This
26 | could have been accomplished by writing a pyi file for xattr but since
27 | we need the runtime support we just add new functions.
28 | """
29 |
30 |
31 | import pathlib
32 | import typing
33 |
34 | XAttrItem = typing.Union[
35 | int, # an open file descriptor, not wrapped by an object
36 | pathlib.Path, # pathlib path object
37 | str, # basic path string
38 | typing.IO, # an open file descriptor, wrapped by an object
39 | ]
40 | Namespace = typing.Optional[bytes]
41 |
42 |
43 | def get(
44 | item: XAttrItem,
45 | name: str,
46 | *,
47 | nofollow: bool = False,
48 | namespace: Namespace = None
49 | ) -> bytes:
50 | """Get an xattr from the target item and name.
51 | See docs for PyXattr module for details.
52 | """
53 | import xattr # type: ignore
54 |
55 | kwargs: dict[str, typing.Any] = {"nofollow": nofollow}
56 | if namespace is not None:
57 | kwargs["namespace"] = namespace
58 | return xattr.get(item, name, **kwargs)
59 |
60 |
61 | def set(
62 | item: XAttrItem,
63 | name: str,
64 | value: str,
65 | *,
66 | flags: typing.Optional[int] = None,
67 | nofollow: bool = False,
68 | namespace: Namespace = None
69 | ) -> None:
70 | """Set an xattr. See docs for PyXattr module for details."""
71 | import xattr # type: ignore
72 |
73 | kwargs: dict[str, typing.Any] = {"nofollow": nofollow}
74 | if flags is not None:
75 | kwargs["flags"] = flags
76 | if namespace is not None:
77 | kwargs["namespace"] = namespace
78 | return xattr.set(item, name, value, **kwargs)
79 |
--------------------------------------------------------------------------------
/sambacc/commands/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/samba-in-kubernetes/sambacc/bf6b8c3de914a980056987862fc3a2db55b79c88/sambacc/commands/__init__.py
--------------------------------------------------------------------------------
/sambacc/commands/check.py:
--------------------------------------------------------------------------------
1 | #
2 | # sambacc: a samba container configuration tool
3 | # Copyright (C) 2021 John Mulligan
4 | #
5 | # This program is free software: you can redistribute it and/or modify
6 | # it under the terms of the GNU General Public License as published by
7 | # the Free Software Foundation, either version 3 of the License, or
8 | # (at your option) any later version.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program. If not, see
17 | #
18 |
19 | from sambacc import samba_cmds, ctdb
20 |
21 | from .cli import commands, Context, Fail
22 |
23 |
24 | def _check_args(parser):
25 | parser.add_argument(
26 | "target",
27 | choices=["winbind", "ctdb-nodestatus"],
28 | help="Name of the target subsystem to check.",
29 | )
30 |
31 |
32 | @commands.command(name="check", arg_func=_check_args)
33 | def check(ctx: Context) -> None:
34 | """Check that a given subsystem is functioning."""
35 | if ctx.cli.target == "winbind":
36 | cmd = samba_cmds.wbinfo["--ping"]
37 | samba_cmds.execute(cmd)
38 | elif ctx.cli.target == "ctdb-nodestatus":
39 | ctdb.check_nodestatus()
40 | else:
41 | raise Fail("unknown subsystem: {}".format(ctx.cli.target))
42 |
--------------------------------------------------------------------------------
/sambacc/commands/config.py:
--------------------------------------------------------------------------------
1 | #
2 | # sambacc: a samba container configuration tool
3 | # Copyright (C) 2021 John Mulligan
4 | #
5 | # This program is free software: you can redistribute it and/or modify
6 | # it under the terms of the GNU General Public License as published by
7 | # the Free Software Foundation, either version 3 of the License, or
8 | # (at your option) any later version.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program. If not, see
17 | #
18 |
19 | import argparse
20 | import functools
21 | import logging
22 | import subprocess
23 | import sys
24 | import typing
25 |
26 | from sambacc import config
27 | from sambacc import samba_cmds
28 | from sambacc.simple_waiter import watch
29 | import sambacc.netcmd_loader as nc
30 | import sambacc.paths as paths
31 |
32 | from .cli import (
33 | Context,
34 | best_leader_locator,
35 | best_waiter,
36 | commands,
37 | perms_handler,
38 | setup_steps,
39 | )
40 |
41 | _logger = logging.getLogger(__name__)
42 |
43 |
44 | @commands.command(name="print-config")
45 | def print_config(ctx: Context) -> None:
46 | """Display the samba configuration sourced from the sambacc config
47 | in the format of smb.conf.
48 | """
49 | nc.template_config(sys.stdout, ctx.instance_config)
50 |
51 |
52 | @commands.command(name="import")
53 | @setup_steps.command(name="config")
54 | def import_config(ctx: Context) -> None:
55 | """Import configuration parameters from the sambacc config to
56 | samba's registry config.
57 | """
58 | # there are some expectations about what dirs exist and perms
59 | paths.ensure_samba_dirs()
60 |
61 | loader = nc.NetCmdLoader()
62 | loader.import_config(ctx.instance_config)
63 |
64 |
65 | def _update_config_args(parser: argparse.ArgumentParser) -> None:
66 | parser.add_argument(
67 | "--watch",
68 | action="store_true",
69 | help="If set, watch the source for changes and update config.",
70 | )
71 |
72 |
73 | def _read_config(ctx: Context) -> config.InstanceConfig:
74 | cfgs = ctx.cli.config or []
75 | return config.read_config_files(
76 | cfgs,
77 | require_validation=ctx.require_validation,
78 | opener=ctx.opener,
79 | ).get(ctx.cli.identity)
80 |
81 |
82 | UpdateResult = typing.Tuple[typing.Optional[config.InstanceConfig], bool]
83 |
84 |
85 | def _update_config(
86 | current: config.InstanceConfig,
87 | previous: typing.Optional[config.InstanceConfig],
88 | ensure_paths: bool = True,
89 | notify_server: bool = True,
90 | ) -> UpdateResult:
91 | """Compare the current and previous instance configurations. If they
92 | differ, ensure any new paths, update the samba config, and inform any
93 | running smbds of the new configuration. Return the current config and a
94 | boolean indicating if the instance configs differed.
95 | """
96 | # has the config changed?
97 | changed = current != previous
98 | # ensure share paths exist
99 | if changed and ensure_paths:
100 | for share in current.shares():
101 | path = share.path()
102 | if not path:
103 | continue
104 | _logger.info(f"Ensuring share path: {path}")
105 | paths.ensure_share_dirs(path)
106 | _logger.info(f"Updating permissions if needed: {path}")
107 | perms_handler(share.permissions_config(), path).update()
108 | # update smb config
109 | if changed:
110 | _logger.info("Updating samba configuration")
111 | loader = nc.NetCmdLoader()
112 | loader.import_config(current)
113 | # notify smbd of changes
114 | if changed and notify_server:
115 | subprocess.check_call(
116 | list(samba_cmds.smbcontrol["smbd", "reload-config"])
117 | )
118 | return current, changed
119 |
120 |
121 | def _exec_if_leader(
122 | ctx: Context,
123 | cond_func: typing.Callable[..., UpdateResult],
124 | ) -> typing.Callable[..., UpdateResult]:
125 | """Run the cond func only on "nodes" that are the cluster leader."""
126 |
127 | # CTDB status and leader detection is not changeable at runtime.
128 | # we do not need to account for it changing in the updated config file(s)
129 | @functools.wraps(cond_func)
130 | def _call_if_leader(
131 | current: config.InstanceConfig, previous: config.InstanceConfig
132 | ) -> UpdateResult:
133 | with best_leader_locator(ctx.instance_config) as ll:
134 | if not ll.is_leader():
135 | _logger.info("skipping config update. node not leader")
136 | return None, False
137 | _logger.info("checking for update. node is leader")
138 | result = cond_func(current, previous)
139 | return result
140 |
141 | return _call_if_leader
142 |
143 |
144 | @commands.command(name="update-config", arg_func=_update_config_args)
145 | def update_config(ctx: Context) -> None:
146 | _get_config = functools.partial(_read_config, ctx)
147 | _cmp_func = _update_config
148 |
149 | if ctx.instance_config.with_ctdb:
150 | _logger.info("enabling ctdb support: will check for leadership")
151 | _cmp_func = _exec_if_leader(ctx, _cmp_func)
152 |
153 | if ctx.cli.watch:
154 | _logger.info("will watch configuration source")
155 | waiter = best_waiter(ctx.cli.config)
156 | watch(
157 | waiter,
158 | ctx.instance_config,
159 | _get_config,
160 | _cmp_func,
161 | )
162 | else:
163 | # we pass None as the previous config so that the command is
164 | # not nearly always a no-op when run from the command line.
165 | _cmp_func(_get_config(), None)
166 | return
167 |
--------------------------------------------------------------------------------
/sambacc/commands/dcmain.py:
--------------------------------------------------------------------------------
1 | #
2 | # sambacc: a samba container configuration tool
3 | # Copyright (C) 2021 John Mulligan
4 | #
5 | # This program is free software: you can redistribute it and/or modify
6 | # it under the terms of the GNU General Public License as published by
7 | # the Free Software Foundation, either version 3 of the License, or
8 | # (at your option) any later version.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program. If not, see
17 | #
18 |
19 | import typing
20 |
21 | from . import addc
22 | from . import skips
23 | from .cli import Fail, commands
24 | from .common import (
25 | CommandContext,
26 | enable_logging,
27 | env_to_cli,
28 | global_args,
29 | pre_action,
30 | )
31 |
32 |
33 | default_cfunc = addc.summary
34 |
35 |
36 | def main(args: typing.Optional[typing.Sequence[str]] = None) -> None:
37 | cli = commands.assemble(arg_func=global_args).parse_args(args)
38 | env_to_cli(cli)
39 | enable_logging(cli)
40 | if not cli.identity:
41 | raise Fail("missing container identity")
42 |
43 | pre_action(cli)
44 | ctx = CommandContext(cli)
45 | skip = skips.test(ctx)
46 | if skip:
47 | print(f"Command Skipped: {skip}")
48 | return
49 | cfunc = getattr(cli, "cfunc", default_cfunc)
50 | cfunc(CommandContext(cli))
51 | return
52 |
53 |
54 | if __name__ == "__main__":
55 | main()
56 |
--------------------------------------------------------------------------------
/sambacc/commands/dns.py:
--------------------------------------------------------------------------------
1 | #
2 | # sambacc: a samba container configuration tool
3 | # Copyright (C) 2021 John Mulligan
4 | #
5 | # This program is free software: you can redistribute it and/or modify
6 | # it under the terms of the GNU General Public License as published by
7 | # the Free Software Foundation, either version 3 of the License, or
8 | # (at your option) any later version.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program. If not, see
17 | #
18 |
19 | import argparse
20 | import functools
21 | import logging
22 | import typing
23 |
24 | from sambacc import container_dns
25 |
26 | from .cli import commands, Context, best_waiter, best_leader_locator, Fail
27 |
28 | _logger = logging.getLogger(__name__)
29 |
30 |
31 | def _dns_register_args(parser: argparse.ArgumentParser) -> None:
32 | parser.add_argument(
33 | "--watch",
34 | action="store_true",
35 | help="If set, watch the source for changes and update DNS.",
36 | )
37 | parser.add_argument(
38 | "--domain",
39 | default="",
40 | help="Manually specify parent domain for DNS entries.",
41 | )
42 | parser.add_argument(
43 | "--target",
44 | default=container_dns.EXTERNAL,
45 | choices=[container_dns.EXTERNAL, container_dns.INTERNAL],
46 | help="Register IPs that fulfill the given access target.",
47 | )
48 | parser.add_argument("source", help="Path to source JSON file.")
49 |
50 |
51 | @commands.command(name="dns-register", arg_func=_dns_register_args)
52 | def dns_register(ctx: Context) -> None:
53 | """Register container & container orchestration IPs with AD DNS."""
54 | # This command assumes a cooperating JSON state file.
55 | # This file is expected to be supplied & kept up to date by
56 | # a container-orchestration specific component.
57 | iconfig = ctx.instance_config
58 | domain = ctx.cli.domain or ""
59 | if not domain:
60 | try:
61 | domain = dict(iconfig.global_options())["realm"].lower()
62 | except KeyError:
63 | raise Fail("instance not configured with domain (realm)")
64 |
65 | update_func = functools.partial(
66 | container_dns.parse_and_update,
67 | target_name=ctx.cli.target,
68 | )
69 |
70 | if iconfig.with_ctdb:
71 | _logger.info("enabling ctdb support: will check for leadership")
72 | update_func = _exec_if_leader(iconfig, update_func)
73 |
74 | if ctx.cli.watch:
75 | _logger.info("will watch source")
76 | waiter = best_waiter(ctx.cli.source)
77 | container_dns.watch(
78 | domain,
79 | ctx.cli.source,
80 | update_func,
81 | waiter.wait,
82 | print_func=print,
83 | )
84 | else:
85 | update_func(domain, ctx.cli.source)
86 | return
87 |
88 |
89 | def _exec_if_leader(iconfig, update_func):
90 | def leader_update_func(
91 | domain: str,
92 | source: str,
93 | previous: typing.Optional[container_dns.HostState] = None,
94 | ) -> typing.Tuple[typing.Optional[container_dns.HostState], bool]:
95 | with best_leader_locator(iconfig) as ll:
96 | if not ll.is_leader():
97 | _logger.info("skipping dns update. node not leader")
98 | return previous, False
99 | _logger.info("checking for update. node is leader")
100 | result = update_func(domain, source, previous)
101 | return result
102 |
103 | return leader_update_func
104 |
--------------------------------------------------------------------------------
/sambacc/commands/initialize.py:
--------------------------------------------------------------------------------
1 | #
2 | # sambacc: a samba container configuration tool
3 | # Copyright (C) 2021 John Mulligan
4 | #
5 | # This program is free software: you can redistribute it and/or modify
6 | # it under the terms of the GNU General Public License as published by
7 | # the Free Software Foundation, either version 3 of the License, or
8 | # (at your option) any later version.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program. If not, see
17 | #
18 |
19 | import logging
20 | import typing
21 |
22 | from sambacc import ctdb
23 | from sambacc import paths
24 | import sambacc.nsswitch_loader as nsswitch
25 |
26 | from . import config # noqa: F401
27 | from . import users # noqa: F401
28 | from .cli import commands, perms_handler, setup_steps, Context
29 |
30 |
31 | _logger = logging.getLogger(__name__)
32 |
33 |
34 | @setup_steps.command("nsswitch")
35 | def _import_nsswitch(ctx: Context) -> None:
36 | # should nsswitch validation/edit be conditional only on ads?
37 | paths = ["/etc/nsswitch.conf", "/usr/etc/nsswitch.conf"]
38 | for path in paths:
39 | nss = nsswitch.NameServiceSwitchLoader(path)
40 | try:
41 | nss.read()
42 | if not nss.winbind_enabled():
43 | nss.ensure_winbind_enabled()
44 | nss.write("/etc/nsswitch.conf")
45 | return
46 | except FileNotFoundError:
47 | pass
48 |
49 | raise FileNotFoundError(f"Failed to open {' or '.join(paths)}")
50 |
51 |
52 | @setup_steps.command("smb_ctdb")
53 | def _smb_conf_for_ctdb(ctx: Context) -> None:
54 | if ctx.instance_config.with_ctdb and ctx.expects_ctdb:
55 | _logger.info("Enabling ctdb in samba config file")
56 | ctdb.ensure_smb_conf(ctx.instance_config)
57 |
58 |
59 | @setup_steps.command("ctdb_config")
60 | def _ctdb_conf_for_ctdb(ctx: Context) -> None:
61 | if ctx.instance_config.with_ctdb and ctx.expects_ctdb:
62 | _logger.info("Ensuring ctdb config")
63 | ctdb.ensure_ctdb_conf(ctx.instance_config)
64 |
65 |
66 | @setup_steps.command("ctdb_nodes")
67 | def _ctdb_nodes_exists(ctx: Context) -> None:
68 | if ctx.instance_config.with_ctdb and ctx.expects_ctdb:
69 | _logger.info("Ensuring ctdb nodes file")
70 | persistent_path = ctx.instance_config.ctdb_config()["nodes_path"]
71 | ctdb.ensure_ctdb_nodes(
72 | ctdb_nodes=ctdb.read_ctdb_nodes(persistent_path),
73 | real_path=persistent_path,
74 | )
75 |
76 |
77 | @setup_steps.command("ctdb_etc")
78 | def _ctdb_etc_files(ctx: Context) -> None:
79 | if ctx.instance_config.with_ctdb and ctx.expects_ctdb:
80 | _logger.info("Ensuring ctdb etc files")
81 | ctdb.ensure_ctdbd_etc_files(iconfig=ctx.instance_config)
82 |
83 |
84 | @setup_steps.command("share_paths")
85 | @commands.command(name="ensure-share-paths")
86 | def ensure_share_paths(ctx: Context) -> None:
87 | """Ensure the paths defined by the configuration exist."""
88 | # currently this is completely ignorant of things like vfs
89 | # modules that might "virtualize" the share path. It just
90 | # assumes that the path in the configuration is an absolute
91 | # path in the file system.
92 | for share in ctx.instance_config.shares():
93 | path = share.path()
94 | if not path:
95 | continue
96 | _logger.info(f"Ensuring share path: {path}")
97 | paths.ensure_share_dirs(path)
98 | _logger.info(f"Updating permissions if needed: {path}")
99 | perms_handler(share.permissions_config(), path).update()
100 |
101 |
102 | _default_setup_steps = [
103 | "config",
104 | "users",
105 | "smb_ctdb",
106 | "users_passdb",
107 | "nsswitch",
108 | ]
109 |
110 |
111 | def setup_step_names():
112 | """Return a list of names for the steps that init supports."""
113 | return list(setup_steps.dict().keys())
114 |
115 |
116 | @commands.command(name="init")
117 | def init_container(
118 | ctx: Context, steps: typing.Optional[typing.Iterable[str]] = None
119 | ) -> None:
120 | """Initialize the entire container environment."""
121 | steps = _default_setup_steps if steps is None else list(steps)
122 | cmds = setup_steps.dict()
123 | for step_name in steps:
124 | cmds[step_name].cmd_func(ctx)
125 |
--------------------------------------------------------------------------------
/sambacc/commands/join.py:
--------------------------------------------------------------------------------
1 | #
2 | # sambacc: a samba container configuration tool
3 | # Copyright (C) 2021 John Mulligan
4 | #
5 | # This program is free software: you can redistribute it and/or modify
6 | # it under the terms of the GNU General Public License as published by
7 | # the Free Software Foundation, either version 3 of the License, or
8 | # (at your option) any later version.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program. If not, see
17 | #
18 |
19 | import sys
20 | import typing
21 |
22 | import sambacc.join as joinutil
23 |
24 | from .cli import (
25 | Context,
26 | Fail,
27 | Parser,
28 | best_waiter,
29 | commands,
30 | toggle_option,
31 | )
32 |
33 |
34 | def _print_join_error(err: typing.Any) -> None:
35 | print(f"ERROR: {err}", file=sys.stderr)
36 | for suberr in getattr(err, "errors", []):
37 | print(f" - {suberr}", file=sys.stderr)
38 |
39 |
40 | def _add_join_sources(joiner: joinutil.Joiner, cli: typing.Any) -> None:
41 | if cli.insecure or getattr(cli, "insecure_auto_join", False):
42 | upass = joinutil.UserPass(cli.username, cli.password)
43 | joiner.add_pw_source(upass)
44 | if cli.files:
45 | for path in cli.join_files or []:
46 | joiner.add_file_source(path)
47 | if cli.odj_files:
48 | for path in cli.odj_files:
49 | joiner.add_odj_file_source(path)
50 | if cli.interactive:
51 | upass = joinutil.UserPass(cli.username)
52 | joiner.add_interactive_source(upass)
53 |
54 |
55 | def _join_args_common(parser: Parser) -> None:
56 | toggle_option(
57 | parser,
58 | arg="--insecure",
59 | dest="insecure",
60 | helpfmt="{} taking user/password from CLI or environment.",
61 | )
62 | toggle_option(
63 | parser,
64 | arg="--files",
65 | dest="files",
66 | helpfmt="{} reading user/password from JSON files.",
67 | )
68 | parser.add_argument(
69 | "--join-file",
70 | "-j",
71 | dest="join_files",
72 | action="append",
73 | help="Path to file with user/password in JSON format.",
74 | )
75 | parser.add_argument(
76 | "--odj-file",
77 | dest="odj_files",
78 | action="append",
79 | help="Path to an Offline Domain Join (ODJ) provisioning data file",
80 | )
81 |
82 |
83 | def _join_args(parser: Parser) -> None:
84 | parser.set_defaults(insecure=False, files=True, interactive=True)
85 | _join_args_common(parser)
86 | toggle_option(
87 | parser,
88 | arg="--interactive",
89 | dest="interactive",
90 | helpfmt="{} interactive password prompt.",
91 | )
92 |
93 |
94 | @commands.command(name="join", arg_func=_join_args)
95 | def join(ctx: Context) -> None:
96 | """Perform a domain join. The supported sources for join
97 | can be provided by supplying command line arguments.
98 | This includes an *insecure* mode that sources the password
99 | from the CLI or environment. Use this only on
100 | testing/non-production purposes.
101 | """
102 | # maybe in the future we'll have more secure methods
103 | joiner = joinutil.Joiner(ctx.cli.join_marker, opener=ctx.opener)
104 | _add_join_sources(joiner, ctx.cli)
105 | try:
106 | joiner.join()
107 | except joinutil.JoinError as err:
108 | _print_join_error(err)
109 | raise Fail("failed to join to a domain")
110 |
111 |
112 | def _must_join_args(parser: Parser) -> None:
113 | parser.set_defaults(insecure=False, files=True, wait=True)
114 | _join_args_common(parser)
115 | toggle_option(
116 | parser,
117 | arg="--wait",
118 | dest="wait",
119 | helpfmt="{} waiting until a join is done.",
120 | )
121 |
122 |
123 | @commands.command(name="must-join", arg_func=_must_join_args)
124 | def must_join(ctx: Context) -> None:
125 | """If possible, perform an unattended domain join. Otherwise,
126 | exit or block until a join has been perfmed by another process.
127 | """
128 | joiner = joinutil.Joiner(ctx.cli.join_marker, opener=ctx.opener)
129 | if joiner.did_join():
130 | print("already joined")
131 | return
132 | # Interactive join is not allowed on must-join
133 | setattr(ctx.cli, "interactive", False)
134 | _add_join_sources(joiner, ctx.cli)
135 | if ctx.cli.wait:
136 | waiter = best_waiter(ctx.cli.join_marker, max_timeout=120)
137 | joinutil.join_when_possible(
138 | joiner, waiter, error_handler=_print_join_error
139 | )
140 | else:
141 | try:
142 | joiner.join()
143 | except joinutil.JoinError as err:
144 | _print_join_error(err)
145 | raise Fail(
146 | "failed to join to a domain - waiting for join is disabled"
147 | )
148 |
--------------------------------------------------------------------------------
/sambacc/commands/main.py:
--------------------------------------------------------------------------------
1 | #
2 | # sambacc: a samba container configuration tool
3 | # Copyright (C) 2021 John Mulligan
4 | #
5 | # This program is free software: you can redistribute it and/or modify
6 | # it under the terms of the GNU General Public License as published by
7 | # the Free Software Foundation, either version 3 of the License, or
8 | # (at your option) any later version.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program. If not, see
17 | #
18 |
19 | import typing
20 |
21 |
22 | from . import config as config_cmds
23 | from . import skips
24 | from .cli import commands, Fail
25 | from .common import (
26 | CommandContext,
27 | enable_logging,
28 | env_to_cli,
29 | global_args,
30 | pre_action,
31 | )
32 |
33 | default_cfunc = config_cmds.print_config
34 |
35 |
36 | def main(args: typing.Optional[typing.Sequence[str]] = None) -> None:
37 | commands.include_multiple(
38 | [".check", ".ctdb", ".dns", ".initialize", ".join", ".run", ".users"]
39 | )
40 |
41 | cli = commands.assemble(arg_func=global_args).parse_args(args)
42 | env_to_cli(cli)
43 | enable_logging(cli)
44 | if not cli.identity:
45 | raise Fail("missing container identity")
46 |
47 | pre_action(cli)
48 | ctx = CommandContext(cli)
49 | skip = skips.test(ctx)
50 | if skip:
51 | print(f"Command Skipped: {skip}")
52 | return
53 | cfunc = getattr(cli, "cfunc", default_cfunc)
54 | cfunc(ctx)
55 | return
56 |
57 |
58 | if __name__ == "__main__":
59 | main()
60 |
--------------------------------------------------------------------------------
/sambacc/commands/remotecontrol/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/samba-in-kubernetes/sambacc/bf6b8c3de914a980056987862fc3a2db55b79c88/sambacc/commands/remotecontrol/__init__.py
--------------------------------------------------------------------------------
/sambacc/commands/remotecontrol/main.py:
--------------------------------------------------------------------------------
1 | #
2 | # sambacc: a samba container configuration tool
3 | # Copyright (C) 2025 John Mulligan
4 | #
5 | # This program is free software: you can redistribute it and/or modify
6 | # it under the terms of the GNU General Public License as published by
7 | # the Free Software Foundation, either version 3 of the License, or
8 | # (at your option) any later version.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program. If not, see
17 | #
18 |
19 | import sys
20 | import typing
21 |
22 |
23 | from .. import skips
24 | from ..cli import Context, Fail, commands
25 | from ..common import (
26 | CommandContext,
27 | enable_logging,
28 | env_to_cli,
29 | global_args,
30 | pre_action,
31 | )
32 |
33 |
34 | def _default(ctx: Context) -> None:
35 | sys.stdout.write(f"{sys.argv[0]} requires a subcommand, like 'serve'.\n")
36 | sys.exit(1)
37 |
38 |
39 | def main(args: typing.Optional[typing.Sequence[str]] = None) -> None:
40 | pkg = "sambacc.commands.remotecontrol"
41 | commands.include(".server", package=pkg)
42 |
43 | cli = commands.assemble(arg_func=global_args).parse_args(args)
44 | env_to_cli(cli)
45 | enable_logging(cli)
46 | if not cli.identity:
47 | raise Fail("missing container identity")
48 |
49 | pre_action(cli)
50 | ctx = CommandContext(cli)
51 | skip = skips.test(ctx)
52 | if skip:
53 | print(f"Command Skipped: {skip}")
54 | return
55 | cfunc = getattr(cli, "cfunc", _default)
56 | cfunc(ctx)
57 | return
58 |
59 |
60 | if __name__ == "__main__":
61 | main()
62 |
--------------------------------------------------------------------------------
/sambacc/commands/remotecontrol/server.py:
--------------------------------------------------------------------------------
1 | #
2 | # sambacc: a samba container configuration tool
3 | # Copyright (C) 2025 John Mulligan
4 | #
5 | # This program is free software: you can redistribute it and/or modify
6 | # it under the terms of the GNU General Public License as published by
7 | # the Free Software Foundation, either version 3 of the License, or
8 | # (at your option) any later version.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program. If not, see
17 | #
18 |
19 | import argparse
20 | import logging
21 | import signal
22 | import sys
23 | import typing
24 |
25 | from ..cli import Context, Fail, commands
26 |
27 | _logger = logging.getLogger(__name__)
28 | _MTLS = "mtls"
29 | _FORCE = "force"
30 |
31 |
32 | def _serve_args(parser: argparse.ArgumentParser) -> None:
33 | parser.add_argument(
34 | "--address",
35 | "-a",
36 | help="Specify an {address:port} value to bind to.",
37 | )
38 | # Force an explicit choice of (the only) rpc type in order to clearly
39 | # prepare the space for possible alternatives
40 | egroup = parser.add_mutually_exclusive_group(required=True)
41 | egroup.add_argument(
42 | "--grpc",
43 | dest="rpc_type",
44 | action="store_const",
45 | default="grpc",
46 | const="grpc",
47 | help="Use gRPC",
48 | )
49 | # security settings
50 | parser.add_argument(
51 | "--insecure",
52 | action="store_true",
53 | help="Disable TLS",
54 | )
55 | parser.add_argument(
56 | "--allow-modify",
57 | choices=(_MTLS, _FORCE),
58 | default=_MTLS,
59 | help="Control modification mode",
60 | )
61 | parser.add_argument(
62 | "--tls-key",
63 | help="Server TLS Key",
64 | )
65 | parser.add_argument(
66 | "--tls-cert",
67 | help="Server TLS Certificate",
68 | )
69 | parser.add_argument(
70 | "--tls-ca-cert",
71 | help="CA Certificate",
72 | )
73 |
74 |
75 | class Restart(Exception):
76 | pass
77 |
78 |
79 | @commands.command(name="serve", arg_func=_serve_args)
80 | def serve(ctx: Context) -> None:
81 | """Start an RPC server."""
82 |
83 | def _handler(*args: typing.Any) -> None:
84 | raise Restart()
85 |
86 | signal.signal(signal.SIGHUP, _handler)
87 | while True:
88 | try:
89 | _serve(ctx)
90 | return
91 | except KeyboardInterrupt:
92 | _logger.info("Exiting")
93 | sys.exit(0)
94 | except Restart:
95 | _logger.info("Re-starting server")
96 | continue
97 |
98 |
99 | def _serve(ctx: Context) -> None:
100 | import sambacc.grpc.backend
101 | import sambacc.grpc.server
102 |
103 | config = sambacc.grpc.server.ServerConfig()
104 | config.insecure = bool(ctx.cli.insecure)
105 | if ctx.cli.address:
106 | config.address = ctx.cli.address
107 | if not (ctx.cli.insecure or ctx.cli.tls_key):
108 | raise Fail("Specify --tls-key=... or --insecure")
109 | if not (ctx.cli.insecure or ctx.cli.tls_cert):
110 | raise Fail("Specify --tls-cert=... or --insecure")
111 | if ctx.cli.tls_key:
112 | config.server_key = _read(ctx, ctx.cli.tls_key)
113 | if ctx.cli.tls_cert:
114 | config.server_cert = _read(ctx, ctx.cli.tls_cert)
115 | if ctx.cli.tls_ca_cert:
116 | config.ca_cert = _read(ctx, ctx.cli.tls_ca_cert)
117 | config.read_only = not (
118 | ctx.cli.allow_modify == _FORCE
119 | or (not config.insecure and config.ca_cert)
120 | )
121 |
122 | backend = sambacc.grpc.backend.ControlBackend(ctx.instance_config)
123 | sambacc.grpc.server.serve(config, backend)
124 |
125 |
126 | def _read(ctx: Context, path_or_url: str) -> bytes:
127 | with ctx.opener.open(path_or_url) as fh:
128 | content = fh.read()
129 | return content if isinstance(content, bytes) else content.encode()
130 |
--------------------------------------------------------------------------------
/sambacc/commands/run.py:
--------------------------------------------------------------------------------
1 | #
2 | # sambacc: a samba container configuration tool
3 | # Copyright (C) 2021 John Mulligan
4 | #
5 | # This program is free software: you can redistribute it and/or modify
6 | # it under the terms of the GNU General Public License as published by
7 | # the Free Software Foundation, either version 3 of the License, or
8 | # (at your option) any later version.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program. If not, see
17 | #
18 |
19 | import contextlib
20 | import logging
21 | import signal
22 | import time
23 | import typing
24 |
25 | from sambacc import samba_cmds
26 | import sambacc.paths as paths
27 |
28 | from .cli import commands, Context, Fail
29 | from .initialize import init_container, setup_step_names
30 | from .join import join
31 |
32 |
33 | _logger = logging.getLogger(__name__)
34 |
35 | INIT_ALL = "init-all"
36 | SMBD = "smbd"
37 | WINBINDD = "winbindd"
38 | CTDBD = "ctdbd"
39 | TARGETS = [SMBD, WINBINDD, CTDBD]
40 |
41 |
42 | class WaitForCTDBCondition:
43 | def met(self, ctx: Context) -> bool:
44 | target = getattr(ctx.cli, "target", None)
45 | if target == CTDBD:
46 | raise Fail(f"Can not start and wait for {CTDBD}")
47 | _logger.debug("Condition required: ctdb pnn available")
48 | import sambacc.ctdb
49 |
50 | pnn = sambacc.ctdb.current_pnn()
51 | ok = pnn is not None
52 | _logger.debug(
53 | "Condition %s: ctdb pnn available: %s",
54 | "met" if ok else "not met",
55 | pnn,
56 | )
57 | return ok
58 |
59 |
60 | _wait_for_conditions = {"ctdb": WaitForCTDBCondition}
61 |
62 |
63 | def _run_container_args(parser):
64 | parser.add_argument(
65 | "--no-init",
66 | action="store_true",
67 | help=(
68 | "(DEPRECATED - see --setup) Do not initialize the container"
69 | " envionment. Only start running the target process."
70 | ),
71 | )
72 | _setup_choices = [INIT_ALL] + list(setup_step_names())
73 | parser.add_argument(
74 | "--setup",
75 | action="append",
76 | choices=_setup_choices,
77 | help=(
78 | "Specify one or more setup step names to preconfigure the"
79 | " container environment before the server process is started."
80 | " The special 'init-all' name will perform all known setup steps."
81 | ),
82 | )
83 | _wait_for_choices = _wait_for_conditions.keys()
84 | parser.add_argument(
85 | "--wait-for",
86 | action="append",
87 | choices=_wait_for_choices,
88 | help=(
89 | "Specify a condition to wait for prior to starting the server"
90 | " process. Available conditions: `ctdb` - wait for ctdb"
91 | " to run and provide a pnn."
92 | ),
93 | )
94 | parser.add_argument(
95 | "--insecure-auto-join",
96 | action="store_true",
97 | help=(
98 | "Perform an inscure domain join prior to starting a service."
99 | " Based on env vars JOIN_USERNAME and INSECURE_JOIN_PASSWORD."
100 | ),
101 | )
102 | parser.add_argument(
103 | "target",
104 | choices=TARGETS,
105 | help="Which process to run",
106 | )
107 |
108 |
109 | _COND_TIMEOUT = 5 * 60
110 |
111 |
112 | @contextlib.contextmanager
113 | def _timeout(timeout: int) -> typing.Iterator[None]:
114 | def _handler(sig: int, frame: typing.Any) -> None:
115 | raise RuntimeError("timed out waiting for conditions")
116 |
117 | signal.signal(signal.SIGALRM, _handler)
118 | signal.alarm(timeout)
119 | yield
120 | signal.alarm(0)
121 | signal.signal(signal.SIGALRM, signal.SIG_DFL)
122 |
123 |
124 | @commands.command(name="run", arg_func=_run_container_args)
125 | def run_container(ctx: Context) -> None:
126 | """Run a specified server process."""
127 | if ctx.cli.no_init and ctx.cli.setup:
128 | raise Fail("can not specify both --no-init and --setup")
129 |
130 | if ctx.cli.wait_for:
131 | with _timeout(_COND_TIMEOUT):
132 | conditions = [_wait_for_conditions[n]() for n in ctx.cli.wait_for]
133 | while not all(c.met(ctx) for c in conditions):
134 | time.sleep(1)
135 |
136 | # running servers expect to make use of ctdb whenever it is configured
137 | ctx.expects_ctdb = True
138 | if not ctx.cli.no_init and not ctx.cli.setup:
139 | # TODO: drop this along with --no-init and move to a opt-in
140 | # rather than opt-out form of pre-run setup
141 | init_container(ctx)
142 | elif ctx.cli.setup:
143 | steps = list(ctx.cli.setup)
144 | init_container(ctx, steps=(None if INIT_ALL in steps else steps))
145 |
146 | paths.ensure_samba_dirs()
147 | if ctx.cli.target == "smbd":
148 | # execute smbd process
149 | samba_cmds.execute(samba_cmds.smbd_foreground())
150 | elif ctx.cli.target == "winbindd":
151 | if getattr(ctx.cli, "insecure_auto_join", False):
152 | join(ctx)
153 | # execute winbind process
154 | samba_cmds.execute(samba_cmds.winbindd_foreground())
155 | elif ctx.cli.target == "ctdbd":
156 | samba_cmds.execute(samba_cmds.ctdbd_foreground)
157 | else:
158 | raise Fail(f"invalid target process: {ctx.cli.target}")
159 |
--------------------------------------------------------------------------------
/sambacc/commands/skips.py:
--------------------------------------------------------------------------------
1 | #
2 | # sambacc: a samba container configuration tool
3 | # Copyright (C) 2024 John Mulligan
4 | #
5 | # This program is free software: you can redistribute it and/or modify
6 | # it under the terms of the GNU General Public License as published by
7 | # the Free Software Foundation, either version 3 of the License, or
8 | # (at your option) any later version.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program. If not, see
17 | #
18 |
19 | from typing import Optional
20 | import argparse
21 | import os
22 |
23 | from sambacc.typelets import Self
24 |
25 | from .cli import Context
26 |
27 |
28 | class SkipIf:
29 | """Base class for objects used to check if a particular sambacc command
30 | should be skipped.
31 | Skips are useful when different commands are chained together
32 | unconditionally in a configuration file (like k8s init containers) but
33 | certain commmands should not be run.
34 | """
35 |
36 | NAME: str = ""
37 |
38 | def test(self, ctx: Context) -> Optional[str]:
39 | """Return a string explaining the reason for the skip or None
40 | indicating no skip is desired.
41 | """
42 | raise NotImplementedError() # pragma: nocover
43 |
44 | @classmethod
45 | def parse(cls, value: str) -> Self:
46 | """Parse a string into a skip class arguments."""
47 | raise NotImplementedError() # pragma: nocover
48 |
49 |
50 | class SkipFile(SkipIf):
51 | """Skip execution if a file exists or does not exist.
52 | The input value "file:/foo/bar" will trigger a skip if the file /foo/bar
53 | exists. To skip if a file does not exist, use "file:!/foo/bar" - prefix the
54 | file name with an exclaimation point.
55 | """
56 |
57 | NAME: str = "file"
58 | inverted: bool = False
59 | path: str = ""
60 |
61 | @classmethod
62 | def parse(cls, value: str) -> Self:
63 | obj = cls()
64 | if not value:
65 | raise ValueError("missing path")
66 | if value[0] == "!":
67 | obj.inverted = True
68 | value = value[1:]
69 | obj.path = value
70 | return obj
71 |
72 | def test(self, ctx: Context) -> Optional[str]:
73 | exists = os.path.exists(self.path)
74 | if self.inverted and not exists:
75 | return f"skip-if-file-missing: {self.path} missing"
76 | if not self.inverted and exists:
77 | return f"skip-if-file-exists: {self.path} exists"
78 | return None
79 |
80 |
81 | class SkipEnv(SkipIf):
82 | """Skip execution if an environment variable is, or is not, equal to a
83 | value. The specification is roughly "env:" where op may
84 | be either `==` or `!=`. For example, "env:FLAVOR==cherry" will skip
85 | execution if the environment variable "FLAVOR" contains the value "cherry".
86 | "env:FLAVOR!=cherry" will skip execution if "FLAVOR" contains any value
87 | other than "cherry".
88 | """
89 |
90 | NAME: str = "env"
91 | _EQ = "=="
92 | _NEQ = "!="
93 |
94 | def __init__(self, op: str, var_name: str, value: str) -> None:
95 | self.op = op
96 | self.var_name = var_name
97 | self.target_value = value
98 |
99 | @classmethod
100 | def parse(cls, value: str) -> Self:
101 | if cls._EQ in value:
102 | op = cls._EQ
103 | elif cls._NEQ in value:
104 | op = cls._NEQ
105 | else:
106 | raise ValueError("invalid SkipEnv: missing or invalid operation")
107 | lhv, rhv = value.split(op, 1)
108 | return cls(op, lhv, rhv)
109 |
110 | def test(self, ctx: Context) -> Optional[str]:
111 | env_val = os.environ.get(self.var_name)
112 | if self.op == self._EQ and env_val == self.target_value:
113 | return (
114 | f"env var: {self.var_name}"
115 | f" -> {env_val} {self.op} {self.target_value}"
116 | )
117 | if self.op == self._NEQ and env_val != self.target_value:
118 | return (
119 | f"env var: {self.var_name}"
120 | f" -> {env_val} {self.op} {self.target_value}"
121 | )
122 | return None
123 |
124 |
125 | class SkipAlways(SkipIf):
126 | """Skip execution unconditionally. Must be specified as "always:" and takes
127 | no value after the colon.
128 | """
129 |
130 | NAME: str = "always"
131 |
132 | @classmethod
133 | def parse(cls, value: str) -> Self:
134 | if value:
135 | raise ValueError("always skip takes no value")
136 | return cls()
137 |
138 | def test(self, ctx: Context) -> Optional[str]:
139 | return "always skip"
140 |
141 |
142 | _SKIP_TYPES = [SkipFile, SkipEnv, SkipAlways]
143 |
144 |
145 | def test(
146 | ctx: Context, *, conditions: Optional[list[SkipIf]] = None
147 | ) -> Optional[str]:
148 | """Return a string explaining the reason for a skip or None indicating
149 | no skip should be performed. Typically the skip conditions will be
150 | derived from the command line arguments but can be passed in manually
151 | using the `conditions` keyword argument.
152 | """
153 | if not conditions:
154 | conditions = ctx.cli.skip_conditions or []
155 | for cond in conditions:
156 | skip = cond.test(ctx)
157 | if skip:
158 | return skip
159 | return None
160 |
161 |
162 | def parse(value: str) -> SkipIf:
163 | """Given a string return a SkipIf-based object. Every value must be
164 | prefixed with the skip "type" (the skip type's NAME).
165 | """
166 | if value == "?":
167 | # A hack to avoid putting tons of documentation into the help output.
168 | raise argparse.ArgumentTypeError(_help_info())
169 | for sk in _SKIP_TYPES:
170 | assert issubclass(sk, SkipIf)
171 | prefix = f"{sk.NAME}:"
172 | plen = len(prefix)
173 | if value.startswith(prefix):
174 | return sk.parse(value[plen:])
175 | raise KeyError("no matching skip rule for: {value!r}")
176 |
177 |
178 | def _help_info() -> str:
179 | msgs = ["Skip conditions help details:", ""]
180 | for sk in _SKIP_TYPES:
181 | assert issubclass(sk, SkipIf)
182 | msgs.append(f"== Skip execution on condition `{sk.NAME}` ==")
183 | assert sk.__doc__
184 | for line in sk.__doc__.splitlines():
185 | msgs.append(line.strip())
186 | msgs.append("")
187 | return "\n".join(msgs)
188 |
--------------------------------------------------------------------------------
/sambacc/commands/users.py:
--------------------------------------------------------------------------------
1 | #
2 | # sambacc: a samba container configuration tool
3 | # Copyright (C) 2021 John Mulligan
4 | #
5 | # This program is free software: you can redistribute it and/or modify
6 | # it under the terms of the GNU General Public License as published by
7 | # the Free Software Foundation, either version 3 of the License, or
8 | # (at your option) any later version.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program. If not, see
17 | #
18 |
19 | import sambacc.passdb_loader as passdb
20 | import sambacc.passwd_loader as ugl
21 |
22 | from .cli import commands, setup_steps, Context
23 |
24 |
25 | @commands.command(name="import-users")
26 | def import_users(ctx: Context) -> None:
27 | """Import users and groups from the sambacc config to the passwd
28 | and group files to support local (non-domain based) login.
29 | """
30 | import_sys_users(ctx)
31 | import_passdb_users(ctx)
32 |
33 |
34 | @setup_steps.command("users")
35 | def import_sys_users(ctx: Context) -> None:
36 | """Import users and groups from sambacc config to the passwd and
37 | group files.
38 | """
39 | etc_passwd_loader = ugl.PasswdFileLoader(ctx.cli.etc_passwd_path)
40 | etc_group_loader = ugl.GroupFileLoader(ctx.cli.etc_group_path)
41 |
42 | etc_passwd_loader.read()
43 | etc_group_loader.read()
44 | for u in ctx.instance_config.users():
45 | etc_passwd_loader.add_user(u)
46 | for g in ctx.instance_config.groups():
47 | etc_group_loader.add_group(g)
48 | etc_passwd_loader.write()
49 | etc_group_loader.write()
50 |
51 |
52 | @setup_steps.command("users_passdb")
53 | def import_passdb_users(ctx: Context) -> None:
54 | """Import users into samba's passdb."""
55 | smb_passdb_loader = passdb.PassDBLoader()
56 | for u in ctx.instance_config.users():
57 | smb_passdb_loader.add_user(u)
58 | return
59 |
--------------------------------------------------------------------------------
/sambacc/container_dns.py:
--------------------------------------------------------------------------------
1 | #
2 | # sambacc: a samba container configuration tool
3 | # Copyright (C) 2021 John Mulligan
4 | #
5 | # This program is free software: you can redistribute it and/or modify
6 | # it under the terms of the GNU General Public License as published by
7 | # the Free Software Foundation, either version 3 of the License, or
8 | # (at your option) any later version.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program. If not, see
17 | #
18 |
19 | from __future__ import annotations
20 |
21 | import json
22 | import subprocess
23 | import typing
24 |
25 | from sambacc import samba_cmds
26 |
27 | EXTERNAL: str = "external"
28 | INTERNAL: str = "internal"
29 |
30 |
31 | class HostState:
32 | T = typing.TypeVar("T", bound="HostState")
33 |
34 | def __init__(
35 | self, ref: str = "", items: typing.Optional[list[HostInfo]] = None
36 | ) -> None:
37 | self.ref: str = ref
38 | self.items: list[HostInfo] = items or []
39 |
40 | @classmethod
41 | def from_dict(cls: typing.Type[T], d: dict[str, typing.Any]) -> T:
42 | return cls(
43 | ref=d["ref"],
44 | items=[HostInfo.from_dict(i) for i in d.get("items", [])],
45 | )
46 |
47 | def __eq__(self, other: typing.Any) -> bool:
48 | return (
49 | self.ref == other.ref
50 | and len(self.items) == len(other.items)
51 | and all(s == o for (s, o) in zip(self.items, other.items))
52 | )
53 |
54 |
55 | class HostInfo:
56 | T = typing.TypeVar("T", bound="HostInfo")
57 |
58 | def __init__(
59 | self, name: str = "", ipv4_addr: str = "", target: str = ""
60 | ) -> None:
61 | self.name = name
62 | self.ipv4_addr = ipv4_addr
63 | self.target = target
64 |
65 | @classmethod
66 | def from_dict(cls: typing.Type[T], d: dict[str, typing.Any]) -> T:
67 | return cls(
68 | name=d["name"],
69 | ipv4_addr=d["ipv4"],
70 | target=d.get("target", ""),
71 | )
72 |
73 | def __eq__(self, other: typing.Any) -> bool:
74 | return (
75 | self.name == other.name
76 | and self.ipv4_addr == other.ipv4_addr
77 | and self.target == other.target
78 | )
79 |
80 |
81 | def parse(fh: typing.IO) -> HostState:
82 | return HostState.from_dict(json.load(fh))
83 |
84 |
85 | def parse_file(path: str) -> HostState:
86 | with open(path) as fh:
87 | return parse(fh)
88 |
89 |
90 | def match_target(state: HostState, target_name: str) -> list[HostInfo]:
91 | return [h for h in state.items if h.target == target_name]
92 |
93 |
94 | def register(
95 | domain: str,
96 | hs: HostState,
97 | prefix: typing.Optional[list[str]] = None,
98 | target_name: str = EXTERNAL,
99 | ) -> bool:
100 | updated = False
101 | for item in match_target(hs, target_name):
102 | ip = item.ipv4_addr
103 | fqdn = "{}.{}".format(item.name, domain)
104 | cmd = samba_cmds.net["ads", "-P", "dns", "register", fqdn, ip]
105 | if prefix is not None:
106 | cmd.cmd_prefix = prefix
107 | subprocess.check_call(list(cmd))
108 | updated = True
109 | return updated
110 |
111 |
112 | def parse_and_update(
113 | domain: str,
114 | source: str,
115 | previous: typing.Optional[HostState] = None,
116 | target_name: str = EXTERNAL,
117 | reg_func: typing.Callable = register,
118 | ) -> typing.Tuple[HostState, bool]:
119 | hs = parse_file(source)
120 | if previous is not None and hs == previous:
121 | # no changes
122 | return hs, False
123 | updated = reg_func(domain, hs, target_name=target_name)
124 | return hs, updated
125 |
126 |
127 | # TODO: replace this with the common version added to simple_waiter
128 | def watch(
129 | domain: str,
130 | source: str,
131 | update_func: typing.Callable,
132 | pause_func: typing.Callable,
133 | print_func: typing.Optional[typing.Callable],
134 | ) -> None:
135 | previous = None
136 | while True:
137 | try:
138 | previous, updated = update_func(domain, source, previous)
139 | except FileNotFoundError:
140 | if print_func:
141 | print_func(f"Source file [{source}] not found")
142 | updated = False
143 | previous = None
144 | if updated and print_func:
145 | print_func("Updating external dns registrations")
146 | try:
147 | pause_func()
148 | except KeyboardInterrupt:
149 | return
150 |
--------------------------------------------------------------------------------
/sambacc/grpc/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/samba-in-kubernetes/sambacc/bf6b8c3de914a980056987862fc3a2db55b79c88/sambacc/grpc/__init__.py
--------------------------------------------------------------------------------
/sambacc/grpc/backend.py:
--------------------------------------------------------------------------------
1 | #
2 | # sambacc: a samba container configuration tool (and more)
3 | # Copyright (C) 2025 John Mulligan
4 | #
5 | # This program is free software: you can redistribute it and/or modify
6 | # it under the terms of the GNU General Public License as published by
7 | # the Free Software Foundation, either version 3 of the License, or
8 | # (at your option) any later version.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program. If not, see
17 | #
18 |
19 | from typing import Any, Union, Optional
20 |
21 | import dataclasses
22 | import json
23 | import os
24 | import subprocess
25 |
26 | from sambacc.typelets import Self
27 | import sambacc.config
28 | import sambacc.samba_cmds
29 |
30 |
31 | @dataclasses.dataclass
32 | class Versions:
33 | samba_version: str = ""
34 | sambacc_version: str = ""
35 | container_version: str = ""
36 |
37 |
38 | @dataclasses.dataclass
39 | class SessionCrypto:
40 | cipher: str
41 | degree: str
42 |
43 | @classmethod
44 | def load(cls, json_object: dict[str, Any]) -> Self:
45 | cipher = json_object.get("cipher", "")
46 | cipher = "" if cipher == "-" else cipher
47 | degree = json_object.get("degree", "")
48 | return cls(cipher=cipher, degree=degree)
49 |
50 |
51 | @dataclasses.dataclass
52 | class Session:
53 | session_id: str
54 | username: str
55 | groupname: str
56 | remote_machine: str
57 | hostname: str
58 | session_dialect: str
59 | uid: int
60 | gid: int
61 | encryption: Optional[SessionCrypto] = None
62 | signing: Optional[SessionCrypto] = None
63 |
64 | @classmethod
65 | def load(cls, json_object: dict[str, Any]) -> Self:
66 | _encryption = json_object.get("encryption")
67 | encryption = SessionCrypto.load(_encryption) if _encryption else None
68 | _signing = json_object.get("signing")
69 | signing = SessionCrypto.load(_signing) if _signing else None
70 | return cls(
71 | session_id=json_object.get("session_id", ""),
72 | username=json_object.get("username", ""),
73 | groupname=json_object.get("groupname", ""),
74 | remote_machine=json_object.get("remote_machine", ""),
75 | hostname=json_object.get("hostname", ""),
76 | session_dialect=json_object.get("session_dialect", ""),
77 | uid=int(json_object.get("uid", -1)),
78 | gid=int(json_object.get("gid", -1)),
79 | encryption=encryption,
80 | signing=signing,
81 | )
82 |
83 |
84 | @dataclasses.dataclass
85 | class TreeConnection:
86 | tcon_id: str
87 | session_id: str
88 | service_name: str
89 |
90 | @classmethod
91 | def load(cls, json_object: dict[str, Any]) -> Self:
92 | return cls(
93 | tcon_id=json_object.get("tcon_id", ""),
94 | session_id=json_object.get("session_id", ""),
95 | service_name=json_object.get("service", ""),
96 | )
97 |
98 |
99 | @dataclasses.dataclass
100 | class Status:
101 | timestamp: str
102 | version: str
103 | sessions: list[Session]
104 | tcons: list[TreeConnection]
105 |
106 | @classmethod
107 | def load(cls, json_object: dict[str, Any]) -> Self:
108 | return cls(
109 | timestamp=json_object.get("timestamp", ""),
110 | version=json_object.get("version", ""),
111 | sessions=[
112 | Session.load(v)
113 | for _, v in json_object.get("sessions", {}).items()
114 | ],
115 | tcons=[
116 | TreeConnection.load(v)
117 | for _, v in json_object.get("tcons", {}).items()
118 | ],
119 | )
120 |
121 | @classmethod
122 | def parse(cls, txt: Union[str, bytes]) -> Self:
123 | return cls.load(json.loads(txt))
124 |
125 |
126 | class ControlBackend:
127 | def __init__(self, config: sambacc.config.InstanceConfig) -> None:
128 | self._config = config
129 |
130 | def _samba_version(self) -> str:
131 | smbd_ver = sambacc.samba_cmds.smbd["--version"]
132 | res = subprocess.run(list(smbd_ver), check=True, capture_output=True)
133 | return res.stdout.decode().strip()
134 |
135 | def _sambacc_version(self) -> str:
136 | try:
137 | import sambacc._version
138 |
139 | return sambacc._version.version
140 | except ImportError:
141 | return "(unknown)"
142 |
143 | def _container_version(self) -> str:
144 | return os.environ.get("SAMBA_CONTAINER_VERSION", "(unknown)")
145 |
146 | def get_versions(self) -> Versions:
147 | versions = Versions()
148 | versions.samba_version = self._samba_version()
149 | versions.sambacc_version = self._sambacc_version()
150 | versions.container_version = self._container_version()
151 | return versions
152 |
153 | def is_clustered(self) -> bool:
154 | return self._config.with_ctdb
155 |
156 | def get_status(self) -> Status:
157 | smbstatus = sambacc.samba_cmds.smbstatus["--json"]
158 | proc = subprocess.Popen(
159 | list(smbstatus),
160 | stdout=subprocess.PIPE,
161 | stderr=subprocess.PIPE,
162 | )
163 | # TODO: the json output of smbstatus is potentially large
164 | # investigate streaming reads instead of fully buffered read
165 | # later
166 | stdout, stderr = proc.communicate()
167 | if proc.returncode != 0:
168 | raise RuntimeError(
169 | f"smbstatus error: {proc.returncode}: {stderr!r}"
170 | )
171 | return Status.parse(stdout)
172 |
173 | def close_share(self, share_name: str, denied_users: bool) -> None:
174 | _close = "close-denied-share" if denied_users else "close-share"
175 | cmd = sambacc.samba_cmds.smbcontrol["smbd", _close, share_name]
176 | subprocess.run(list(cmd), check=True)
177 |
178 | def kill_client(self, ip_address: str) -> None:
179 | cmd = sambacc.samba_cmds.smbcontrol[
180 | "smbd", "kill-client-ip", ip_address
181 | ]
182 | subprocess.run(list(cmd), check=True)
183 |
--------------------------------------------------------------------------------
/sambacc/grpc/generated/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/samba-in-kubernetes/sambacc/bf6b8c3de914a980056987862fc3a2db55b79c88/sambacc/grpc/generated/__init__.py
--------------------------------------------------------------------------------
/sambacc/grpc/protobufs/control.proto:
--------------------------------------------------------------------------------
1 | // Use proto3 as the older protobuf we need for centos doesn't support
2 | // 2023 edition.
3 | syntax = "proto3";
4 |
5 | // Some requests and respose types are currently empty. However, we don't use
6 | // Empty in the case we want to extend them in the future.
7 |
8 | // --- Info ---
9 | // Provide version numbers and basic information about the samba
10 | // container instance. Mainly for debugging.
11 |
12 | message InfoRequest {}
13 |
14 | message SambaInfo {
15 | string version = 1;
16 | bool clustered = 2;
17 | }
18 |
19 | message SambaContainerInfo {
20 | string sambacc_version = 1;
21 | string container_version = 2;
22 | }
23 |
24 | message GeneralInfo {
25 | SambaInfo samba_info = 1;
26 | SambaContainerInfo container_info = 2;
27 | }
28 |
29 | // --- Status ---
30 | // Fetch status information from the samba instance. Includes basic
31 | // information about connected clients.
32 |
33 | message StatusRequest {}
34 |
35 | message SessionCrypto {
36 | string cipher = 1;
37 | string degree = 2;
38 | }
39 |
40 | message SessionInfo {
41 | string session_id = 1;
42 | string username = 2;
43 | string groupname = 3;
44 | string remote_machine = 4;
45 | string hostname = 5;
46 | string session_dialect = 6;
47 | uint32 uid = 7;
48 | uint32 gid = 8;
49 | SessionCrypto encryption = 9;
50 | SessionCrypto signing = 10;
51 | }
52 |
53 | message ConnInfo {
54 | string tcon_id = 1;
55 | string session_id = 2;
56 | string service_name = 3;
57 | }
58 |
59 | message StatusInfo {
60 | string server_timestamp = 1;
61 | repeated SessionInfo sessions = 2;
62 | repeated ConnInfo tree_connections = 3;
63 | }
64 |
65 | // --- CloseShare ---
66 | // Close shares to clients.
67 |
68 | message CloseShareRequest {
69 | string share_name = 1;
70 | bool denied_users = 2;
71 | }
72 |
73 | message CloseShareInfo {}
74 |
75 | // --- KillClientConnection ---
76 | // Forcibly disconnect a client.
77 |
78 | message KillClientRequest {
79 | string ip_address = 1;
80 | }
81 |
82 | message KillClientInfo {}
83 |
84 | // --- define rpcs ---
85 |
86 | service SambaControl {
87 | rpc Info (InfoRequest) returns (GeneralInfo);
88 | rpc Status (StatusRequest) returns (StatusInfo);
89 | rpc CloseShare (CloseShareRequest) returns (CloseShareInfo);
90 | rpc KillClientConnection (KillClientRequest) returns (KillClientInfo);
91 | }
92 |
--------------------------------------------------------------------------------
/sambacc/inotify_waiter.py:
--------------------------------------------------------------------------------
1 | #
2 | # sambacc: a samba container configuration tool
3 | # Copyright (C) 2021 John Mulligan
4 | #
5 | # This program is free software: you can redistribute it and/or modify
6 | # it under the terms of the GNU General Public License as published by
7 | # the Free Software Foundation, either version 3 of the License, or
8 | # (at your option) any later version.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program. If not, see
17 | #
18 |
19 | import os
20 | import typing
21 |
22 | import inotify_simple as _inotify # type: ignore
23 |
24 | DEFAULT_TIMEOUT = 300
25 |
26 |
27 | class INotify:
28 | """A waiter that monitors a file path for changes, based on inotify.
29 |
30 | Inotify is used to monitor the specified path for changes (writes).
31 | It stops waiting when the file is changed or the timeout is reached.
32 |
33 | A `print_func` can be specified as a simple logging method.
34 | """
35 |
36 | timeout: int = DEFAULT_TIMEOUT
37 | print_func = None
38 |
39 | def __init__(
40 | self,
41 | path: str,
42 | print_func: typing.Optional[typing.Callable] = None,
43 | timeout: typing.Optional[int] = None,
44 | ) -> None:
45 | if timeout is not None:
46 | self.timeout = timeout
47 | self.print_func = print_func
48 | self._inotify = _inotify.INotify()
49 | dirpath, fpath = os.path.split(path)
50 | if not dirpath:
51 | dirpath = "."
52 | if not fpath:
53 | raise ValueError("a file path is required")
54 | self._dir = dirpath
55 | self._name = fpath
56 | self._mask = _inotify.flags.DELETE | _inotify.flags.CLOSE_WRITE
57 | self._inotify.add_watch(self._dir, self._mask)
58 |
59 | def close(self) -> None:
60 | self._inotify.close()
61 |
62 | def _print(self, msg: str) -> None:
63 | if self.print_func:
64 | self.print_func("[inotify waiter] {}".format(msg))
65 |
66 | def acted(self) -> None:
67 | return # noop for inotify waiter
68 |
69 | def wait(self) -> None:
70 | next(self._wait())
71 |
72 | def _get_events(self) -> list[typing.Any]:
73 | timeout = 1000 * self.timeout
74 | self._print("waiting {}ms for activity...".format(timeout))
75 | events = self._inotify.read(timeout=timeout)
76 | if not events:
77 | # use "None" as a sentinel for a timeout, otherwise we can not
78 | # tell if its all events that didn't match or a true timeout
79 | return [None]
80 | # filter out events we don't care about
81 | return [
82 | event
83 | for event in events
84 | if (event.name == self._name)
85 | and ((event.mask & _inotify.flags.CLOSE_WRITE) != 0)
86 | ]
87 |
88 | def _wait(self) -> typing.Iterator[None]:
89 | while True:
90 | for event in self._get_events():
91 | if event is None:
92 | self._print("timed out")
93 | yield None
94 | else:
95 | self._print(f"{self._name} modified")
96 | yield None
97 |
--------------------------------------------------------------------------------
/sambacc/jfile.py:
--------------------------------------------------------------------------------
1 | #
2 | # sambacc: a samba container configuration tool
3 | # Copyright (C) 2021 John Mulligan
4 | #
5 | # This program is free software: you can redistribute it and/or modify
6 | # it under the terms of the GNU General Public License as published by
7 | # the Free Software Foundation, either version 3 of the License, or
8 | # (at your option) any later version.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program. If not, see
17 | #
18 | """Utilities for working with JSON data stored in a file system file.
19 | """
20 |
21 | import fcntl
22 | import json
23 | import os
24 | import typing
25 |
26 | from sambacc.typelets import ExcType, ExcValue, ExcTraceback, Self
27 |
28 | OPEN_RO = os.O_RDONLY
29 | OPEN_RW = os.O_CREAT | os.O_RDWR
30 |
31 |
32 | def open(path: str, flags: int, mode: int = 0o644) -> typing.IO:
33 | """A wrapper around open to open JSON files for read or read/write.
34 | `flags` must be os.open type flags. Use `OPEN_RO` and `OPEN_RW` for
35 | convenience.
36 | """
37 | return os.fdopen(os.open(path, flags, mode), "r+")
38 |
39 |
40 | def load(
41 | fh: typing.IO, default: typing.Optional[dict[str, typing.Any]] = None
42 | ) -> typing.Any:
43 | """Similar to json.load, but returns the `default` value if fh refers to an
44 | empty file. fh must be seekable."""
45 | if fh.read(4) == "":
46 | # probe it to see if its an empty file
47 | data = default
48 | else:
49 | fh.seek(0)
50 | data = json.load(fh)
51 | return data
52 |
53 |
54 | def dump(data: typing.Any, fh: typing.IO) -> None:
55 | """Similar to json.dump, but truncates the file before writing in order
56 | to avoid appending data to the file. fh must be seekable.
57 | """
58 | fh.seek(0)
59 | fh.truncate(0)
60 | json.dump(data, fh)
61 |
62 |
63 | def flock(fh: typing.IO) -> None:
64 | """A simple wrapper around flock."""
65 | fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
66 |
67 |
68 | class ClusterMetaJSONHandle:
69 | def __init__(self, fh: typing.IO) -> None:
70 | self._fh = fh
71 |
72 | def load(self) -> typing.Any:
73 | return load(self._fh, {})
74 |
75 | def dump(self, data: typing.Any) -> None:
76 | dump(data, self._fh)
77 | self._fh.flush()
78 | os.fsync(self._fh)
79 |
80 | def __enter__(self) -> Self:
81 | return self
82 |
83 | def __exit__(
84 | self, exc_type: ExcType, exc_val: ExcValue, exc_tb: ExcTraceback
85 | ) -> None:
86 | self._fh.close()
87 |
88 |
89 | class ClusterMetaJSONFile:
90 | def __init__(self, path: str) -> None:
91 | self.path = path
92 |
93 | def open(
94 | self, *, read: bool = True, write: bool = False, locked: bool = False
95 | ) -> ClusterMetaJSONHandle:
96 | if read and write:
97 | flags = OPEN_RW
98 | elif read:
99 | flags = OPEN_RO
100 | else:
101 | raise ValueError("write-only not supported")
102 | fh = open(self.path, flags)
103 | try:
104 | if locked:
105 | flock(fh)
106 | except Exception:
107 | fh.close()
108 | raise
109 | return ClusterMetaJSONHandle(fh)
110 |
--------------------------------------------------------------------------------
/sambacc/leader.py:
--------------------------------------------------------------------------------
1 | #
2 | # sambacc: a samba container configuration tool
3 | # Copyright (C) 2021 John Mulligan
4 | #
5 | # This program is free software: you can redistribute it and/or modify
6 | # it under the terms of the GNU General Public License as published by
7 | # the Free Software Foundation, either version 3 of the License, or
8 | # (at your option) any later version.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program. If not, see
17 | #
18 |
19 | import typing
20 |
21 | from sambacc.typelets import ExcType, ExcValue, ExcTraceback
22 |
23 |
24 | class LeaderStatus(typing.Protocol):
25 | """Fetches information about the current cluster leader."""
26 |
27 | def is_leader(self) -> bool:
28 | """Return true if the current node is the leader."""
29 | ... # pragma: no cover
30 |
31 |
32 | class LeaderLocator(typing.Protocol):
33 | """Acquire state needed to determine or fix a cluster leader.
34 | Can be used for purely informational types or types that
35 | actually acquire cluster leadership if needed.
36 | """
37 |
38 | def __enter__(self) -> LeaderStatus:
39 | """Enter context manager. Returns LeaderStatus."""
40 | ... # pragma: no cover
41 |
42 | def __exit__(
43 | self, exc_type: ExcType, exc_val: ExcValue, exc_tb: ExcTraceback
44 | ) -> bool:
45 | """Exit context manager."""
46 | ... # pragma: no cover
47 |
--------------------------------------------------------------------------------
/sambacc/netcmd_loader.py:
--------------------------------------------------------------------------------
1 | #
2 | # sambacc: a samba container configuration tool
3 | # Copyright (C) 2021 John Mulligan
4 | #
5 | # This program is free software: you can redistribute it and/or modify
6 | # it under the terms of the GNU General Public License as published by
7 | # the Free Software Foundation, either version 3 of the License, or
8 | # (at your option) any later version.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program. If not, see
17 | #
18 |
19 | import subprocess
20 | import typing
21 |
22 | from sambacc import config
23 | from sambacc import samba_cmds
24 |
25 |
26 | class LoaderError(Exception):
27 | pass
28 |
29 |
30 | def template_config(
31 | fh: typing.IO, iconfig: config.SambaConfig, enc: typing.Callable = str
32 | ) -> None:
33 | fh.write(enc("[global]\n"))
34 | for gkey, gval in iconfig.global_options():
35 | fh.write(enc(f"\t{gkey} = {gval}\n"))
36 |
37 | for share in iconfig.shares():
38 | fh.write(enc("\n[{}]\n".format(share.name)))
39 | for skey, sval in share.share_options():
40 | fh.write(enc(f"\t{skey} = {sval}\n"))
41 |
42 |
43 | class NetCmdLoader:
44 | _net_conf = samba_cmds.net["conf"]
45 |
46 | def _cmd(
47 | self, *args: str, **kwargs: typing.Any
48 | ) -> tuple[list[str], typing.Any]:
49 | cmd = list(self._net_conf[args])
50 | return cmd, subprocess.Popen(cmd, **kwargs)
51 |
52 | def _check(self, cli: typing.Any, proc: subprocess.Popen) -> None:
53 | ret = proc.wait()
54 | if ret != 0:
55 | raise LoaderError("failed to run {}".format(cli))
56 |
57 | def import_config(self, iconfig: config.InstanceConfig) -> None:
58 | """Import to entire instance config to samba config."""
59 | cli, proc = self._cmd("import", "/dev/stdin", stdin=subprocess.PIPE)
60 | template_config(proc.stdin, iconfig, enc=samba_cmds.encode)
61 | proc.stdin.close()
62 | self._check(cli, proc)
63 |
64 | def dump(self, out: typing.IO) -> None:
65 | """Dump the current smb config in an smb.conf format.
66 | Writes the dump to `out`.
67 | """
68 | cli, proc = self._cmd("list", stdout=out)
69 | self._check(cli, proc)
70 |
71 | def _parse_shares(self, fh: typing.IO) -> typing.Iterable[str]:
72 | out = []
73 | for line in fh.readlines():
74 | line = line.strip().decode("utf8")
75 | if line == "global":
76 | continue
77 | out.append(line)
78 | return out
79 |
80 | def current_shares(self) -> typing.Iterable[str]:
81 | """Returns a list of current shares."""
82 | cli, proc = self._cmd("listshares", stdout=subprocess.PIPE)
83 | # read and parse shares list
84 | try:
85 | shares = self._parse_shares(proc.stdout)
86 | finally:
87 | self._check(cli, proc)
88 | return shares
89 |
90 | def set(self, section: str, param: str, value: str) -> None:
91 | """Set an individual config parameter."""
92 | cli, proc = self._cmd("setparm", section, param, value)
93 | self._check(cli, proc)
94 |
--------------------------------------------------------------------------------
/sambacc/nsswitch_loader.py:
--------------------------------------------------------------------------------
1 | #
2 | # sambacc: a samba container configuration tool
3 | # Copyright (C) 2021 John Mulligan
4 | #
5 | # This program is free software: you can redistribute it and/or modify
6 | # it under the terms of the GNU General Public License as published by
7 | # the Free Software Foundation, either version 3 of the License, or
8 | # (at your option) any later version.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program. If not, see
17 | #
18 |
19 | import typing
20 |
21 | from .textfile import TextFileLoader
22 |
23 |
24 | class NameServiceSwitchLoader(TextFileLoader):
25 | def __init__(self, path: str) -> None:
26 | super().__init__(path)
27 | self.lines: list[str] = []
28 | self.idx: dict[str, int] = {}
29 |
30 | def loadlines(self, lines: typing.Iterable[str]) -> None:
31 | """Load in the lines from the text source."""
32 | # Ignore comments and blank lines
33 | for line in lines:
34 | if not line.strip() or line.startswith("#"):
35 | continue
36 | self.lines.append(line)
37 | for lnum, line in enumerate(self.lines):
38 | if line.startswith("passwd:"):
39 | self.idx["passwd"] = lnum
40 | if line.startswith("group:"):
41 | self.idx["group"] = lnum
42 |
43 | def dumplines(self) -> typing.Iterable[str]:
44 | """Dump the file content as lines of text."""
45 | prev = None
46 | yield "# Generated by sambacc -- DO NOT EDIT\n"
47 | for line in self.lines:
48 | if prev and not prev.endswith("\n"):
49 | yield "\n"
50 | yield line
51 | prev = line
52 |
53 | def winbind_enabled(self) -> bool:
54 | pline = self.lines[self.idx["passwd"]]
55 | gline = self.lines[self.idx["group"]]
56 | return ("winbind" in pline) and ("winbind" in gline)
57 |
58 | def ensure_winbind_enabled(self) -> None:
59 | pidx = self.idx["passwd"]
60 | if "winbind" not in self.lines[pidx]:
61 | self.lines[pidx] = "passwd: files winbind\n"
62 | gidx = self.idx["group"]
63 | if "winbind" not in self.lines[gidx]:
64 | self.lines[gidx] = "group: files winbind\n"
65 |
--------------------------------------------------------------------------------
/sambacc/opener.py:
--------------------------------------------------------------------------------
1 | #
2 | # sambacc: a samba container configuration tool
3 | # Copyright (C) 2023 John Mulligan
4 | #
5 | # This program is free software: you can redistribute it and/or modify
6 | # it under the terms of the GNU General Public License as published by
7 | # the Free Software Foundation, either version 3 of the License, or
8 | # (at your option) any later version.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program. If not, see
17 | #
18 |
19 | import typing
20 |
21 |
22 | class SchemeNotSupported(Exception):
23 | pass
24 |
25 |
26 | class Opener(typing.Protocol):
27 | """Protocol for a basic opener type that takes a path-ish or uri-ish
28 | string and tries to open it.
29 | """
30 |
31 | def open(self, path_or_uri: str) -> typing.IO:
32 | """Open a specified resource by path or (pseudo) URI."""
33 | ... # pragma: no cover
34 |
35 |
36 | class FallbackOpener:
37 | """FallbackOpener is used to open a path if a the string can not be
38 | opened as a URI/URL.
39 | """
40 |
41 | def __init__(
42 | self,
43 | openers: list[Opener],
44 | open_fn: typing.Optional[typing.Callable[..., typing.IO]] = None,
45 | ) -> None:
46 | self._openers = openers
47 | self._open_fn = open_fn or FileOpener.open
48 |
49 | def open(self, path_or_uri: str) -> typing.IO:
50 | for opener in self._openers:
51 | try:
52 | return opener.open(path_or_uri)
53 | except SchemeNotSupported:
54 | pass
55 | return self._open(path_or_uri)
56 |
57 | def _open(self, path: str) -> typing.IO:
58 | return self._open_fn(path)
59 |
60 |
61 | class FileOpener:
62 | """Minimal opener that only supports opening local files."""
63 |
64 | @staticmethod
65 | def open(path: str) -> typing.IO:
66 | return open(path, "rb")
67 |
--------------------------------------------------------------------------------
/sambacc/passdb_loader.py:
--------------------------------------------------------------------------------
1 | #
2 | # sambacc: a samba container configuration tool
3 | # Copyright (C) 2021 John Mulligan
4 | #
5 | # This program is free software: you can redistribute it and/or modify
6 | # it under the terms of the GNU General Public License as published by
7 | # the Free Software Foundation, either version 3 of the License, or
8 | # (at your option) any later version.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program. If not, see
17 | #
18 |
19 | import typing
20 |
21 | from sambacc import config
22 |
23 | # Do the samba python bindings not export any useful constants?
24 | ACB_DISABLED = 0x00000001
25 | ACB_NORMAL = 0x00000010
26 | ACB_PWNOEXP = 0x00000200
27 |
28 |
29 | def _samba_modules() -> tuple[typing.Any, typing.Any]:
30 | from samba.samba3 import param # type: ignore
31 | from samba.samba3 import passdb # type: ignore
32 |
33 | return param, passdb
34 |
35 |
36 | class PassDBLoader:
37 | def __init__(self, smbconf: typing.Any = None) -> None:
38 | param, passdb = _samba_modules()
39 | lp = param.get_context()
40 | if smbconf is None:
41 | lp.load_default()
42 | else:
43 | lp.load(smbconf)
44 | passdb.set_secrets_dir(lp.get("private dir"))
45 | self._pdb = passdb.PDB(lp.get("passdb backend"))
46 | self._passdb = passdb
47 |
48 | def add_user(self, user_entry: config.UserEntry) -> None:
49 | if not (user_entry.nt_passwd or user_entry.plaintext_passwd):
50 | raise ValueError(
51 | f"user entry {user_entry.username} lacks password value"
52 | )
53 | # probe for an existing user, by name
54 | try:
55 | samu = self._pdb.getsampwnam(user_entry.username)
56 | except self._passdb.error:
57 | samu = None
58 | # if it doesn't exist, create it
59 | if samu is None:
60 | # FIXME, research if there are better flag values to use
61 | acb = ACB_NORMAL | ACB_PWNOEXP
62 | self._pdb.create_user(user_entry.username, acb)
63 | samu = self._pdb.getsampwnam(user_entry.username)
64 | acb = samu.acct_ctrl
65 | # update password/metadata
66 | if user_entry.nt_passwd:
67 | samu.nt_passwd = user_entry.nt_passwd
68 | elif user_entry.plaintext_passwd:
69 | samu.plaintext_passwd = user_entry.plaintext_passwd
70 | # Try to mimic the behavior of smbpasswd and clear the account disabled
71 | # flag when adding or updating the user.
72 | # We don't expect granular, on the fly, user management in the
73 | # container, so it seems pointless to have a user that can't log in.
74 | if acb & ACB_DISABLED:
75 | samu.acct_ctrl = acb & ~ACB_DISABLED
76 | # update the db
77 | self._pdb.update_sam_account(samu)
78 |
--------------------------------------------------------------------------------
/sambacc/passwd_loader.py:
--------------------------------------------------------------------------------
1 | #
2 | # sambacc: a samba container configuration tool
3 | # Copyright (C) 2021 John Mulligan
4 | #
5 | # This program is free software: you can redistribute it and/or modify
6 | # it under the terms of the GNU General Public License as published by
7 | # the Free Software Foundation, either version 3 of the License, or
8 | # (at your option) any later version.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program. If not, see
17 | #
18 |
19 | import typing
20 |
21 | from .textfile import TextFileLoader
22 | from sambacc import config
23 |
24 |
25 | class LineFileLoader(TextFileLoader):
26 | def __init__(self, path: str) -> None:
27 | super().__init__(path)
28 | self.lines: list[str] = []
29 |
30 | def loadlines(self, lines: typing.Iterable[str]) -> None:
31 | """Load in the lines from the text source."""
32 | for line in lines:
33 | self.lines.append(line)
34 |
35 | def dumplines(self) -> typing.Iterable[str]:
36 | """Dump the file content as lines of text."""
37 | prev = None
38 | for line in self.lines:
39 | if prev and not prev.endswith("\n"):
40 | yield "\n"
41 | yield line
42 | prev = line
43 |
44 |
45 | class PasswdFileLoader(LineFileLoader):
46 | def __init__(self, path: str = "/etc/passwd") -> None:
47 | super().__init__(path)
48 | self._usernames: set[str] = set()
49 |
50 | def readfp(self, fp: typing.IO) -> None:
51 | super().readfp(fp)
52 | self._update_usernames_cache()
53 |
54 | def _update_usernames_cache(self) -> None:
55 | for line in self.lines:
56 | if ":" in line:
57 | u = line.split(":")[0]
58 | self._usernames.add(u)
59 |
60 | def add_user(self, user_entry: config.UserEntry) -> None:
61 | if user_entry.username in self._usernames:
62 | return
63 | line = "{}\n".format(":".join(user_entry.passwd_fields()))
64 | self.lines.append(line)
65 | self._usernames.add(user_entry.username)
66 |
67 |
68 | class GroupFileLoader(LineFileLoader):
69 | def __init__(self, path: str = "/etc/group") -> None:
70 | super().__init__(path)
71 | self._groupnames: set[str] = set()
72 |
73 | def readfp(self, fp: typing.IO) -> None:
74 | super().readfp(fp)
75 | self._update_groupnames_cache()
76 |
77 | def _update_groupnames_cache(self) -> None:
78 | for line in self.lines:
79 | if ":" in line:
80 | u = line.split(":")[0]
81 | self._groupnames.add(u)
82 |
83 | def add_group(self, group_entry: config.GroupEntry) -> None:
84 | if group_entry.groupname in self._groupnames:
85 | return
86 | line = "{}\n".format(":".join(group_entry.group_fields()))
87 | self.lines.append(line)
88 | self._groupnames.add(group_entry.groupname)
89 |
--------------------------------------------------------------------------------
/sambacc/paths.py:
--------------------------------------------------------------------------------
1 | #
2 | # sambacc: a samba container configuration tool
3 | # Copyright (C) 2021 John Mulligan
4 | #
5 | # This program is free software: you can redistribute it and/or modify
6 | # it under the terms of the GNU General Public License as published by
7 | # the Free Software Foundation, either version 3 of the License, or
8 | # (at your option) any later version.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program. If not, see
17 | #
18 |
19 | import errno
20 | import os
21 |
22 |
23 | def ensure_samba_dirs(root: str = "/") -> None:
24 | """Ensure that certain directories that samba servers expect will
25 | exist. This is useful when mapping iniitally empty dirs into
26 | the container.
27 | """
28 | smb_dir = os.path.join(root, "var/lib/samba")
29 | smb_private_dir = os.path.join(smb_dir, "private")
30 | smb_run_dir = os.path.join(root, "run/samba")
31 | wb_sockets_dir = os.path.join(smb_run_dir, "winbindd")
32 |
33 | _mkdir(smb_dir)
34 | _mkdir(smb_private_dir)
35 |
36 | _mkdir(smb_run_dir)
37 | _mkdir(wb_sockets_dir)
38 | os.chmod(wb_sockets_dir, 0o755)
39 |
40 |
41 | def _mkdir(path: str) -> None:
42 | try:
43 | os.mkdir(path)
44 | except OSError as err:
45 | if getattr(err, "errno", 0) != errno.EEXIST:
46 | raise
47 |
48 |
49 | def ensure_share_dirs(path: str, root: str = "/") -> None:
50 | """Ensure that the given path exists.
51 | The optional root argument allows "reparenting" the path
52 | into a virtual root dir.
53 | """
54 | while path.startswith("/"):
55 | path = path[1:]
56 | path = os.path.join(root, path)
57 | os.makedirs(path, exist_ok=True)
58 |
--------------------------------------------------------------------------------
/sambacc/permissions.py:
--------------------------------------------------------------------------------
1 | #
2 | # sambacc: a samba container configuration tool
3 | # Copyright (C) 2022 John Mulligan
4 | #
5 | # This program is free software: you can redistribute it and/or modify
6 | # it under the terms of the GNU General Public License as published by
7 | # the Free Software Foundation, either version 3 of the License, or
8 | # (at your option) any later version.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program. If not, see
17 | #
18 |
19 | from __future__ import annotations
20 |
21 | import contextlib
22 | import datetime
23 | import errno
24 | import logging
25 | import os
26 | import typing
27 |
28 | from sambacc import _xattr as xattr
29 |
30 |
31 | _logger = logging.getLogger(__name__)
32 |
33 |
34 | class PermissionsHandler(typing.Protocol):
35 | def has_status(self) -> bool:
36 | """Return true if the path has status metadata."""
37 | ... # pragma: no cover
38 |
39 | def status_ok(self) -> bool:
40 | """Return true if status is OK (no changes are needed)."""
41 | ... # pragma: no cover
42 |
43 | def update(self) -> None:
44 | """Update the permissions as needed."""
45 | ... # pragma: no cover
46 |
47 | def path(self) -> str:
48 | """Return the path under consideration."""
49 | ... # pragma: no cover
50 |
51 |
52 | @contextlib.contextmanager
53 | def _opendir(path: str) -> typing.Iterator[int]:
54 | dfd: int = os.open(path, os.O_DIRECTORY)
55 | try:
56 | yield dfd
57 | os.fsync(dfd)
58 | except OSError:
59 | os.sync()
60 | raise
61 | finally:
62 | os.close(dfd)
63 |
64 |
65 | class NoopPermsHandler:
66 | def __init__(
67 | self,
68 | path: str,
69 | status_xattr: str,
70 | options: typing.Dict[str, str],
71 | root: str = "/",
72 | ) -> None:
73 | self._path = path
74 |
75 | def path(self) -> str:
76 | return self._path
77 |
78 | def has_status(self) -> bool:
79 | return False
80 |
81 | def status_ok(self) -> bool:
82 | return True
83 |
84 | def update(self) -> None:
85 | pass
86 |
87 |
88 | class InitPosixPermsHandler:
89 | """Initialize posix permissions on a share (directory).
90 |
91 | This handler sets posix permissions only.
92 |
93 | It will only set the permissions when the status xattr does not
94 | match the expected prefix value. This prevents it from overwiting
95 | permissions that may have been changed intentionally after
96 | share initialization.
97 | """
98 |
99 | _default_mode = 0o777
100 | _default_status_prefix = "v1"
101 |
102 | def __init__(
103 | self,
104 | path: str,
105 | status_xattr: str,
106 | options: typing.Dict[str, str],
107 | root: str = "/",
108 | ) -> None:
109 | self._path = path
110 | self._root = root
111 | self._xattr = status_xattr
112 | try:
113 | self._mode = int(options["mode"], 8)
114 | except KeyError:
115 | self._mode = self._default_mode
116 | try:
117 | self._prefix = options["status_prefix"]
118 | except KeyError:
119 | self._prefix = self._default_status_prefix
120 |
121 | def path(self) -> str:
122 | return self._path
123 |
124 | def _full_path(self) -> str:
125 | return os.path.join(self._root, self._path.lstrip("/"))
126 |
127 | def has_status(self) -> bool:
128 | try:
129 | self._get_status()
130 | return True
131 | except KeyError:
132 | return False
133 |
134 | def status_ok(self) -> bool:
135 | try:
136 | sval = self._get_status()
137 | except KeyError:
138 | return False
139 | curr_prefix = sval.split("/")[0]
140 | return curr_prefix == self._prefix
141 |
142 | def update(self) -> None:
143 | if self.status_ok():
144 | return
145 | self._set_perms()
146 | self._set_status()
147 |
148 | def _get_status(self) -> str:
149 | path = self._full_path()
150 | _logger.debug("reading xattr %r: %r", self._xattr, path)
151 | try:
152 | value = xattr.get(path, self._xattr, nofollow=True)
153 | except OSError as err:
154 | if err.errno == errno.ENODATA:
155 | raise KeyError(self._xattr)
156 | raise
157 | return value.decode("utf8")
158 |
159 | def _set_perms(self) -> None:
160 | # yeah, this is really simple compared to all the state management
161 | # stuff.
162 | path = self._full_path()
163 | with _opendir(path) as dfd:
164 | os.fchmod(dfd, self._mode)
165 |
166 | def _timestamp(self) -> str:
167 | return datetime.datetime.now().strftime("%s")
168 |
169 | def _set_status(self) -> None:
170 | # we save the marker prefix followed by a timestamp as a debugging hint
171 | ts = self._timestamp()
172 | val = f"{self._prefix}/{ts}"
173 | path = self._full_path()
174 | _logger.debug("setting xattr %r=%r: %r", self._xattr, val, self._path)
175 | with _opendir(path) as dfd:
176 | xattr.set(dfd, self._xattr, val, nofollow=True)
177 |
178 |
179 | class AlwaysPosixPermsHandler(InitPosixPermsHandler):
180 | """Works like the init handler, but always sets the permissions,
181 | even if the status xattr exists and is valid.
182 | May be useful for testing and debugging.
183 | """
184 |
185 | def update(self) -> None:
186 | self._set_perms()
187 | self._set_status()
188 |
--------------------------------------------------------------------------------
/sambacc/samba_cmds.py:
--------------------------------------------------------------------------------
1 | #
2 | # sambacc: a samba container configuration tool
3 | # Copyright (C) 2021 John Mulligan
4 | #
5 | # This program is free software: you can redistribute it and/or modify
6 | # it under the terms of the GNU General Public License as published by
7 | # the Free Software Foundation, either version 3 of the License, or
8 | # (at your option) any later version.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program. If not, see
17 | #
18 |
19 | from __future__ import annotations
20 |
21 | import os
22 | import typing
23 |
24 | DebugLevel = typing.Optional[str]
25 | ArgList = typing.Optional[list[str]]
26 |
27 | _GLOBAL_PREFIX: list[str] = []
28 | _GLOBAL_DEBUG: str = ""
29 |
30 |
31 | # Known flags for SAMBA_SPECIFICS env variable
32 | _DAEMON_CLI_STDOUT_OPT: str = "daemon_cli_debug_output"
33 | _CTDB_LEADER_ADMIN_CMD: str = "ctdb_leader_admin_command"
34 | _CTDB_RADOS_MUTEX_SKIP_REG: str = "ctdb_rados_mutex_skip_reg"
35 |
36 |
37 | def get_samba_specifics() -> typing.Set[str]:
38 | value = os.environ.get("SAMBA_SPECIFICS", "")
39 | out = set()
40 | if value:
41 | for v in value.split(","):
42 | out.add(v)
43 | return out
44 |
45 |
46 | def _daemon_stdout_opt(daemon: str) -> str:
47 | if daemon == "smbd":
48 | opt = "--log-stdout"
49 | else:
50 | opt = "--stdout"
51 | opt_lst = get_samba_specifics()
52 | if _DAEMON_CLI_STDOUT_OPT in opt_lst:
53 | opt = "--debug-stdout"
54 | return opt
55 |
56 |
57 | def ctdb_leader_admin_cmd() -> str:
58 | leader_cmd = "recmaster"
59 | opt_lst = get_samba_specifics()
60 | if _CTDB_LEADER_ADMIN_CMD in opt_lst:
61 | leader_cmd = "leader"
62 | return leader_cmd
63 |
64 |
65 | def ctdb_rados_mutex_skip_registration_opt() -> str:
66 | if _CTDB_RADOS_MUTEX_SKIP_REG in get_samba_specifics():
67 | return "-R" # skip registration option
68 | return ""
69 |
70 |
71 | def set_global_prefix(lst: list[str]) -> None:
72 | _GLOBAL_PREFIX[:] = lst
73 |
74 |
75 | def set_global_debug(level: str) -> None:
76 | global _GLOBAL_DEBUG
77 | _GLOBAL_DEBUG = level
78 |
79 |
80 | def _to_args(value: typing.Any) -> list[str]:
81 | if isinstance(value, str):
82 | return [value]
83 | return [str(v) for v in value]
84 |
85 |
86 | class CommandArgs:
87 | """A utility class for building command line commands."""
88 |
89 | _name: str
90 | args: list[str]
91 | cmd_prefix: list[str]
92 |
93 | def __init__(self, name: str, args: ArgList = None):
94 | self._name = name
95 | self.args = args or []
96 | self.cmd_prefix = []
97 |
98 | def __getitem__(self, new_value: typing.Any) -> CommandArgs:
99 | return self.__class__(self._name, args=self.args + _to_args(new_value))
100 |
101 | def raw_args(self) -> list[str]:
102 | return [self._name] + self.args
103 |
104 | def prefix_args(self) -> list[str]:
105 | return list(_GLOBAL_PREFIX) + list(self.cmd_prefix)
106 |
107 | def argv(self) -> list[str]:
108 | return self.prefix_args() + self.raw_args()
109 |
110 | def __iter__(self) -> typing.Iterator[str]:
111 | return iter(self.argv())
112 |
113 | def __repr__(self) -> str:
114 | return "CommandArgs({!r}, {!r})".format(self._name, self.args)
115 |
116 | @property
117 | def name(self) -> str:
118 | """Return the command to be executed. This may differ from
119 | the underlying command.
120 | """
121 | return self.argv()[0]
122 |
123 |
124 | class SambaCommand(CommandArgs):
125 | """A utility class for building samba (or any) command line command."""
126 |
127 | debug: DebugLevel
128 |
129 | def __init__(
130 | self, name: str, args: ArgList = None, debug: DebugLevel = None
131 | ):
132 | super().__init__(name, args)
133 | self.debug = debug
134 |
135 | def __getitem__(self, new_value: typing.Any) -> SambaCommand:
136 | return self.__class__(
137 | self._name,
138 | args=self.args + _to_args(new_value),
139 | debug=self.debug,
140 | )
141 |
142 | def _debug_args(self, dlvl: str = "--debuglevel={}") -> list[str]:
143 | if self.debug:
144 | return [dlvl.format(self.debug)]
145 | if _GLOBAL_DEBUG:
146 | return [dlvl.format(_GLOBAL_DEBUG)]
147 | return []
148 |
149 | def raw_args(self) -> list[str]:
150 | return [self._name] + self.args + self._debug_args()
151 |
152 | def __repr__(self) -> str:
153 | return "SambaCommand({!r}, {!r}, {!r})".format(
154 | self._name, self.args, self.debug
155 | )
156 |
157 |
158 | net = SambaCommand("net")
159 |
160 | wbinfo = CommandArgs("wbinfo")
161 |
162 | smbd = SambaCommand("/usr/sbin/smbd")
163 |
164 | winbindd = SambaCommand("/usr/sbin/winbindd")
165 |
166 | samba_dc = SambaCommand("/usr/sbin/samba")
167 |
168 |
169 | def smbd_foreground() -> SambaCommand:
170 | return smbd[
171 | "--foreground", _daemon_stdout_opt("smbd"), "--no-process-group"
172 | ]
173 |
174 |
175 | def winbindd_foreground() -> SambaCommand:
176 | return winbindd[
177 | "--foreground", _daemon_stdout_opt("winbindd"), "--no-process-group"
178 | ]
179 |
180 |
181 | def samba_dc_foreground() -> SambaCommand:
182 | return samba_dc["--foreground", _daemon_stdout_opt("samba")]
183 |
184 |
185 | ctdbd = SambaCommand("/usr/sbin/ctdbd")
186 |
187 | ctdbd_foreground = ctdbd["--interactive"]
188 |
189 | ltdbtool = CommandArgs("ltdbtool")
190 |
191 | ctdb = CommandArgs("ctdb")
192 |
193 | sambatool = SambaCommand("samba-tool")
194 |
195 | smbcontrol = SambaCommand("smbcontrol")
196 |
197 | smbstatus = SambaCommand("smbstatus")
198 |
199 | ctdb_mutex_ceph_rados_helper = SambaCommand(
200 | "/usr/libexec/ctdb/ctdb_mutex_ceph_rados_helper"
201 | )
202 |
203 |
204 | def encode(value: typing.Union[str, bytes, None]) -> bytes:
205 | if value is None:
206 | return b""
207 | elif isinstance(value, str):
208 | value = value.encode("utf8")
209 | return value
210 |
211 |
212 | def execute(cmd: CommandArgs) -> None:
213 | """Exec into the command specified (without forking)."""
214 | os.execvp(cmd.name, cmd.argv()) # pragma: no cover
215 |
--------------------------------------------------------------------------------
/sambacc/schema/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/samba-in-kubernetes/sambacc/bf6b8c3de914a980056987862fc3a2db55b79c88/sambacc/schema/__init__.py
--------------------------------------------------------------------------------
/sambacc/schema/tool.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/python3
2 | """Convert or compare schema files written in YAML to the corresponding
3 | files stored in JSON.
4 | """
5 |
6 | import argparse
7 | import collections
8 | import json
9 | import os
10 | import pprint
11 | import subprocess
12 | import sys
13 |
14 | import yaml
15 |
16 |
17 | nameparts = collections.namedtuple("nameparts", "full head ext")
18 | filepair = collections.namedtuple("filepair", "origin dest format")
19 |
20 |
21 | def _namesplit(name):
22 | head, ext = name.split(".", 1)
23 | return nameparts(name, head, ext)
24 |
25 |
26 | def _pyname(np):
27 | head = np.head.replace("-", "_")
28 | if np.ext.startswith("schema."):
29 | head += "_schema"
30 | ext = "py"
31 | return nameparts(f"{head}.{ext}", head, ext)
32 |
33 |
34 | def _format_black(path):
35 | # black does not formally have an api. Safeset way to use it is via
36 | # the cli
37 | path = os.path.abspath(path)
38 | # the --preview arg allows black to break up long strings that
39 | # the general check would discover and complain about. Otherwise
40 | # we'd be forced to ignore the formatting on these .py files.
41 | subprocess.run(["black", "-l78", "--preview", path], check=True)
42 |
43 |
44 | def match(files):
45 | yamls = []
46 | jsons = []
47 | pys = []
48 | for fname in files:
49 | try:
50 | np = _namesplit(fname)
51 | except ValueError:
52 | continue
53 | if np.ext == "schema.yaml":
54 | yamls.append(np)
55 | if np.ext == "schema.json":
56 | jsons.append(np)
57 | if np.ext == "py":
58 | pys.append(np)
59 | pairs = []
60 | for yname in yamls:
61 | for jname in jsons:
62 | if jname.head == yname.head:
63 | pairs.append(filepair(yname, jname, "JSON"))
64 | break
65 | else:
66 | pairs.append(filepair(yname, None, "JSON"))
67 | for pyname in pys:
68 | if _pyname(yname).head == pyname.head:
69 | pairs.append(filepair(yname, pyname, "PYTHON"))
70 | break
71 | else:
72 | pairs.append(filepair(yname, None, "PYTHON"))
73 | return pairs
74 |
75 |
76 | def report(func, path, yaml_file, json_file, fmt):
77 | needs_update = func(path, yaml_file, json_file, fmt)
78 | json_name = "---" if not json_file else json_file.full
79 | if not needs_update:
80 | print(f"{yaml_file.full} -> {fmt.lower()}:{json_name} OK")
81 | return None
82 | print(f"{yaml_file.full} -> {fmt.lower()}:{json_name} MISMATCH")
83 | return True
84 |
85 |
86 | def update_json(path, yaml_file, json_file):
87 | yaml_path = os.path.join(path, yaml_file.full)
88 | json_path = os.path.join(path, f"{yaml_file.head}.schema.json")
89 | with open(yaml_path) as fh:
90 | yaml_data = yaml.safe_load(fh)
91 | with open(json_path, "w") as fh:
92 | json.dump(yaml_data, fh, indent=2)
93 |
94 |
95 | def compare_json(path, yaml_file, json_file):
96 | if json_file is None:
97 | return True
98 | yaml_path = os.path.join(path, yaml_file.full)
99 | json_path = os.path.join(path, json_file.full)
100 | with open(yaml_path) as fh:
101 | yaml_data = yaml.safe_load(fh)
102 | with open(json_path) as fh:
103 | json_data = json.load(fh)
104 | return yaml_data != json_data
105 |
106 |
107 | def update_py(path, yaml_file, py_file):
108 | yaml_path = os.path.join(path, yaml_file.full)
109 | py_path = os.path.join(path, _pyname(yaml_file).full)
110 | with open(yaml_path) as fh:
111 | yaml_data = yaml.safe_load(fh)
112 | out = []
113 | out.append("#!/usr/bin/python3")
114 | out.append("# --- GENERATED FILE --- DO NOT EDIT --- #")
115 | out.append(f"# --- generated from: {yaml_file.full}")
116 | out.append("")
117 | out.append(
118 | "SCHEMA = " + pprint.pformat(yaml_data, width=800, sort_dicts=False)
119 | )
120 | content = "\n".join(out)
121 | with open(py_path, "w") as fh:
122 | fh.write(content)
123 | _format_black(py_path)
124 |
125 |
126 | def compare_py(path, yaml_file, py_file):
127 | if py_file is None:
128 | return True
129 | yaml_path = os.path.join(path, yaml_file.full)
130 | py_path = os.path.join(path, py_file.full)
131 | with open(yaml_path) as fh:
132 | yaml_data = yaml.safe_load(fh)
133 | with open(py_path) as fh:
134 | py_locals = {}
135 | exec(fh.read(), None, py_locals)
136 | py_data = py_locals.get("SCHEMA") or {}
137 | return yaml_data != py_data
138 |
139 |
140 | def update(path, yaml_data, other_file, fmt):
141 | if fmt == "PYTHON":
142 | return update_py(path, yaml_data, other_file)
143 | return update_json(path, yaml_data, other_file)
144 |
145 |
146 | def compare(path, yaml_data, other_file, fmt):
147 | if fmt == "PYTHON":
148 | return compare_py(path, yaml_data, other_file)
149 | return compare_json(path, yaml_data, other_file)
150 |
151 |
152 | def main():
153 | parser = argparse.ArgumentParser()
154 | parser.add_argument("DIR", default=os.path.dirname(__file__), nargs="?")
155 | parser.add_argument("--update", action="store_true")
156 | cli = parser.parse_args()
157 |
158 | mismatches = []
159 | os.chdir(cli.DIR)
160 | fn = update if cli.update else compare
161 | pairs = match(os.listdir("."))
162 | for pair in pairs:
163 | mismatches.append(report(fn, ".", *pair))
164 | if any(mismatches):
165 | sys.exit(1)
166 |
167 |
168 | if __name__ == "__main__":
169 | main()
170 |
--------------------------------------------------------------------------------
/sambacc/simple_waiter.py:
--------------------------------------------------------------------------------
1 | #
2 | # sambacc: a samba container configuration tool
3 | # Copyright (C) 2021 John Mulligan
4 | #
5 | # This program is free software: you can redistribute it and/or modify
6 | # it under the terms of the GNU General Public License as published by
7 | # the Free Software Foundation, either version 3 of the License, or
8 | # (at your option) any later version.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program. If not, see
17 | #
18 |
19 | import time
20 | import typing
21 |
22 |
23 | def generate_sleeps() -> typing.Iterator[int]:
24 | """Generate sleep times starting with short sleeps and then
25 | getting longer. This assumes that resources may take a bit of
26 | time to settle, but eventually reach a steadier state and don't
27 | require being checked as often.
28 | """
29 | total = 0
30 | while True:
31 | if total > 120:
32 | val = 60
33 | elif total > 10:
34 | val = 5
35 | else:
36 | val = 1
37 | yield val
38 | total += val
39 |
40 |
41 | # It's a bit overkill to have a class for this but I didn't like messing
42 | # around with functools.partial or functions returning functions for this.
43 | # It's also nice to replace the sleep function for unit tests.
44 | class Sleeper:
45 | """It waits only by sleeping. Nothing fancy."""
46 |
47 | def __init__(
48 | self, times: typing.Optional[typing.Iterator[int]] = None
49 | ) -> None:
50 | if times is None:
51 | times = generate_sleeps()
52 | self._times = times
53 | self._sleep = time.sleep
54 |
55 | def wait(self) -> None:
56 | self._sleep(next(self._times))
57 |
58 | def acted(self) -> None:
59 | """Inform the sleeper the caller reacted to a change and
60 | the sleeps should be reset.
61 | """
62 | self.times = generate_sleeps()
63 |
64 |
65 | class Waiter(typing.Protocol):
66 | """Waiter protocol - interfaces common to all waiters."""
67 |
68 | def wait(self) -> None:
69 | """Pause execution for a time."""
70 | ... # pragma: no cover
71 |
72 | def acted(self) -> None:
73 | """Inform that waiter that changes were made."""
74 | ... # pragma: no cover
75 |
76 |
77 | def watch(
78 | waiter: Waiter,
79 | initial_value: typing.Any,
80 | fetch_func: typing.Callable[..., typing.Any],
81 | compare_func: typing.Callable[..., typing.Tuple[typing.Any, bool]],
82 | ) -> None:
83 | """A very simple "event loop" that fetches current data with
84 | `fetch_func`, compares and updates state with `compare_func` and
85 | then waits for new events with `pause_func`.
86 | """
87 | previous = initial_value
88 | while True:
89 | try:
90 | previous, updated = compare_func(fetch_func(), previous)
91 | except FileNotFoundError:
92 | updated = False
93 | previous = None
94 | try:
95 | if updated:
96 | waiter.acted()
97 | waiter.wait()
98 | except KeyboardInterrupt:
99 | return
100 |
--------------------------------------------------------------------------------
/sambacc/smbconf_api.py:
--------------------------------------------------------------------------------
1 | #
2 | # sambacc: a samba container configuration tool
3 | # Copyright (C) 2023 John Mulligan
4 | #
5 | # This program is free software: you can redistribute it and/or modify
6 | # it under the terms of the GNU General Public License as published by
7 | # the Free Software Foundation, either version 3 of the License, or
8 | # (at your option) any later version.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program. If not, see
17 | #
18 |
19 | import typing
20 |
21 |
22 | class ConfigStore(typing.Protocol):
23 | def __getitem__(self, name: str) -> list[tuple[str, str]]:
24 | """Get an item, returning a config section."""
25 | ... # pragma: no cover
26 |
27 | def __setitem__(self, name: str, value: list[tuple[str, str]]) -> None:
28 | """Set a new config section."""
29 | ... # pragma: no cover
30 |
31 | def __iter__(self) -> typing.Iterator[str]:
32 | """Iterate over config sections in the store."""
33 | ... # pragma: no cover
34 |
35 |
36 | class SimpleConfigStore:
37 | def __init__(self) -> None:
38 | self._data: dict[str, list[tuple[str, str]]] = {}
39 |
40 | @property
41 | def writeable(self) -> bool:
42 | """True if using a read-write backend."""
43 | return True
44 |
45 | def __getitem__(self, name: str) -> list[tuple[str, str]]:
46 | return self._data[name]
47 |
48 | def __setitem__(self, name: str, value: list[tuple[str, str]]) -> None:
49 | self._data[name] = value
50 |
51 | def __iter__(self) -> typing.Iterator[str]:
52 | return iter(self._data.keys())
53 |
54 | def import_smbconf(
55 | self, src: ConfigStore, batch_size: typing.Optional[int] = None
56 | ) -> None:
57 | """Import content from one SMBConf configuration object into the
58 | current SMBConf configuration object.
59 |
60 | batch_size is ignored.
61 | """
62 | for sname in src:
63 | self[sname] = src[sname]
64 |
65 |
66 | def write_store_as_smb_conf(out: typing.IO, conf: ConfigStore) -> None:
67 | """Write the configuration store in smb.conf format to `out`."""
68 | # unfortunately, AFAIK, there's no way for an smbconf to write
69 | # into a an smb.conf/ini style file. We have to do it on our own.
70 | # ---
71 | # Make sure global section comes first.
72 | sections = sorted(conf, key=lambda v: 0 if v == "global" else 1)
73 | for sname in sections:
74 | out.write(str("\n[{}]\n".format(sname)))
75 | for skey, sval in conf[sname]:
76 | out.write(str(f"\t{skey} = {sval}\n"))
77 |
--------------------------------------------------------------------------------
/sambacc/smbconf_samba.py:
--------------------------------------------------------------------------------
1 | #
2 | # sambacc: a samba container configuration tool
3 | # Copyright (C) 2023 John Mulligan
4 | #
5 | # This program is free software: you can redistribute it and/or modify
6 | # it under the terms of the GNU General Public License as published by
7 | # the Free Software Foundation, either version 3 of the License, or
8 | # (at your option) any later version.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program. If not, see
17 | #
18 |
19 | import sys
20 | import types
21 | import importlib
22 | import typing
23 | import itertools
24 |
25 | from sambacc.smbconf_api import ConfigStore
26 |
27 |
28 | def _smbconf() -> types.ModuleType:
29 | return importlib.import_module("samba.smbconf")
30 |
31 |
32 | def _s3smbconf() -> types.ModuleType:
33 | return importlib.import_module("samba.samba3.smbconf")
34 |
35 |
36 | def _s3param() -> types.ModuleType:
37 | return importlib.import_module("samba.samba3.param")
38 |
39 |
40 | if sys.version_info >= (3, 11):
41 | from typing import Self as _Self
42 | else:
43 | _Self = typing.TypeVar("_Self", bound="SMBConf")
44 |
45 |
46 | class SMBConf:
47 | """SMBConf wraps the samba smbconf library, supporting reading from and,
48 | when possible, writing to samba configuration backends. The SMBConf type
49 | supports transactions using the context managager interface. The SMBConf
50 | type can read and write configuration based on dictionary-like access,
51 | using shares as the keys. The global configuration is treated like a
52 | special "share".
53 | """
54 |
55 | def __init__(self, smbconf: typing.Any) -> None:
56 | self._smbconf = smbconf
57 |
58 | @classmethod
59 | def from_file(cls: typing.Type[_Self], path: str) -> _Self:
60 | """Open a smb.conf style configuration from the specified path."""
61 | return cls(_smbconf().init_txt(path))
62 |
63 | @classmethod
64 | def from_registry(
65 | cls: typing.Type[_Self],
66 | configfile: str = "/etc/samba/smb.conf",
67 | key: typing.Optional[str] = None,
68 | ) -> _Self:
69 | """Open samba's registry backend for configuration parameters."""
70 | s3_lp = _s3param().get_context()
71 | s3_lp.load(configfile)
72 | return cls(_s3smbconf().init_reg(key))
73 |
74 | @property
75 | def writeable(self) -> bool:
76 | """True if using a read-write backend."""
77 | return self._smbconf.is_writeable()
78 |
79 | # the extraneous `self: _Self` type makes mypy on python <3.11 happy.
80 | # otherwise it complains: `A function returning TypeVar should receive at
81 | # least one argument containing the same TypeVar`
82 | def __enter__(self: _Self) -> _Self:
83 | self._smbconf.transaction_start()
84 | return self
85 |
86 | def __exit__(
87 | self, exc_type: typing.Any, exc_value: typing.Any, tb: typing.Any
88 | ) -> None:
89 | if exc_type is None:
90 | self._smbconf.transaction_commit()
91 | return
92 | return self._smbconf.transaction_cancel()
93 |
94 | def __getitem__(self, name: str) -> list[tuple[str, str]]:
95 | try:
96 | n2, values = self._smbconf.get_share(name)
97 | except _smbconf().SMBConfError as err:
98 | if err.error_code == _smbconf().SBC_ERR_NO_SUCH_SERVICE:
99 | raise KeyError(name)
100 | raise
101 | if name != n2:
102 | raise ValueError(f"section name invalid: {name!r} != {n2!r}")
103 | return values
104 |
105 | def __setitem__(self, name: str, value: list[tuple[str, str]]) -> None:
106 | try:
107 | self._smbconf.delete_share(name)
108 | except _smbconf().SMBConfError as err:
109 | if err.error_code != _smbconf().SBC_ERR_NO_SUCH_SERVICE:
110 | raise
111 | self._smbconf.create_set_share(name, value)
112 |
113 | def __iter__(self) -> typing.Iterator[str]:
114 | return iter(self._smbconf.share_names())
115 |
116 | def import_smbconf(
117 | self, src: ConfigStore, batch_size: typing.Optional[int] = 100
118 | ) -> None:
119 | """Import content from one SMBConf configuration object into the
120 | current SMBConf configuration object.
121 |
122 | Set batch_size to the maximum number of "shares" to import in one
123 | transaction. Set batch_size to None to use only one transaction.
124 | """
125 | if not self.writeable:
126 | raise ValueError("SMBConf is not writable")
127 | if batch_size is None:
128 | return self._import_smbconf_all(src)
129 | return self._import_smbconf_batched(src, batch_size)
130 |
131 | def _import_smbconf_all(self, src: ConfigStore) -> None:
132 | with self:
133 | for sname in src:
134 | self[sname] = src[sname]
135 |
136 | def _import_smbconf_batched(
137 | self, src: ConfigStore, batch_size: int
138 | ) -> None:
139 | # based on a comment in samba's source code for the net command
140 | # only import N 'shares' at a time so that the transaction does
141 | # not exceed talloc memory limits
142 | def _batch_keyfunc(item: tuple[int, str]) -> int:
143 | return item[0] // batch_size
144 |
145 | for _, snames in itertools.groupby(enumerate(src), _batch_keyfunc):
146 | with self:
147 | for _, sname in snames:
148 | self[sname] = src[sname]
149 |
--------------------------------------------------------------------------------
/sambacc/textfile.py:
--------------------------------------------------------------------------------
1 | #
2 | # sambacc: a samba container configuration tool
3 | # Copyright (C) 2021 John Mulligan
4 | #
5 | # This program is free software: you can redistribute it and/or modify
6 | # it under the terms of the GNU General Public License as published by
7 | # the Free Software Foundation, either version 3 of the License, or
8 | # (at your option) any later version.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program. If not, see
17 | #
18 |
19 | import os
20 | import typing
21 |
22 |
23 | class TextFileLoader:
24 | def __init__(self, path: str):
25 | self.path = path
26 |
27 | def read(self) -> None:
28 | with open(self.path) as f:
29 | self.readfp(f)
30 |
31 | def write(self, alternate_path: typing.Optional[str] = None) -> None:
32 | path = self.path
33 | if alternate_path:
34 | path = alternate_path
35 | tpath = self._tmp_path(path)
36 | with open(tpath, "w") as f:
37 | self.writefp(f)
38 | os.rename(tpath, path)
39 |
40 | def _tmp_path(self, path: str) -> str:
41 | # for later: make this smarter
42 | return f"{path}.tmp"
43 |
44 | def readfp(self, fp: typing.IO) -> None:
45 | self.loadlines(fp.readlines())
46 |
47 | def writefp(self, fp: typing.IO) -> None:
48 | for line in self.dumplines():
49 | fp.write(line)
50 | fp.flush()
51 |
52 | def dumplines(self) -> typing.Iterable[str]:
53 | """Must be overridden."""
54 | return []
55 |
56 | def loadlines(self, lines: typing.Iterable[str]) -> None:
57 | """Must be overridden."""
58 | pass
59 |
--------------------------------------------------------------------------------
/sambacc/typelets.py:
--------------------------------------------------------------------------------
1 | #
2 | # sambacc: a samba container configuration tool
3 | # Copyright (C) 2022 John Mulligan
4 | #
5 | # This program is free software: you can redistribute it and/or modify
6 | # it under the terms of the GNU General Public License as published by
7 | # the Free Software Foundation, either version 3 of the License, or
8 | # (at your option) any later version.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program. If not, see
17 | #
18 | """typelets defines common-ish type hinting types that are tedious to
19 | remember/redefine.
20 | """
21 |
22 | from types import TracebackType
23 | import sys
24 | import typing
25 |
26 | ExcType = typing.Optional[typing.Type[BaseException]]
27 | ExcValue = typing.Optional[BaseException]
28 | ExcTraceback = typing.Optional[TracebackType]
29 |
30 |
31 | if sys.version_info >= (3, 11):
32 | from typing import Self
33 | elif typing.TYPE_CHECKING:
34 | from typing_extensions import Self
35 | else:
36 | Self = typing.Any
37 |
--------------------------------------------------------------------------------
/sambacc/url_opener.py:
--------------------------------------------------------------------------------
1 | #
2 | # sambacc: a samba container configuration tool
3 | # Copyright (C) 2023 John Mulligan
4 | #
5 | # This program is free software: you can redistribute it and/or modify
6 | # it under the terms of the GNU General Public License as published by
7 | # the Free Software Foundation, either version 3 of the License, or
8 | # (at your option) any later version.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program. If not, see
17 | #
18 |
19 | import errno
20 | import http
21 | import typing
22 | import urllib.error
23 | import urllib.request
24 |
25 | from .opener import SchemeNotSupported
26 |
27 |
28 | class _UnknownHandler(urllib.request.BaseHandler):
29 | def unknown_open(self, req: urllib.request.Request) -> None:
30 | raise SchemeNotSupported(req.full_url)
31 |
32 |
33 | class URLOpener:
34 | """An Opener type used for fetching remote resources named in
35 | a pseudo-URL (URI-like) style.
36 | By default works like urllib.urlopen but only for HTTP(S).
37 |
38 | Example:
39 | >>> uo = URLOpener()
40 | >>> res = uo.open("http://abc.example.org/foo/x.json")
41 | >>> res.read()
42 | """
43 |
44 | # this list is similar to the defaults found in build_opener
45 | # but only for http/https handlers. No FTP, etc.
46 | _handlers = [
47 | urllib.request.ProxyHandler,
48 | urllib.request.HTTPHandler,
49 | urllib.request.HTTPDefaultErrorHandler,
50 | urllib.request.HTTPRedirectHandler,
51 | urllib.request.HTTPErrorProcessor,
52 | urllib.request.HTTPSHandler,
53 | _UnknownHandler,
54 | ]
55 |
56 | def __init__(self) -> None:
57 | self._opener = urllib.request.OpenerDirector()
58 | for handler in self._handlers:
59 | self._opener.add_handler(handler())
60 |
61 | def open(self, url: str) -> typing.IO:
62 | try:
63 | return self._opener.open(url)
64 | except ValueError as err:
65 | # too bad urllib doesn't use a specific subclass of ValueError here
66 | if "unknown url type" in str(err):
67 | raise SchemeNotSupported(url) from err
68 | raise
69 | except urllib.error.HTTPError as err:
70 | _map_errno(err)
71 | raise
72 |
73 |
74 | _EMAP = {
75 | http.HTTPStatus.NOT_FOUND.value: errno.ENOENT,
76 | http.HTTPStatus.UNAUTHORIZED.value: errno.EPERM,
77 | }
78 |
79 |
80 | def _map_errno(err: urllib.error.HTTPError) -> None:
81 | """While HTTPError is an OSError, it often doesn't have an errno set.
82 | Since our callers care about the errno, do a best effort mapping of
83 | some HTTP statuses to errnos.
84 | """
85 | if getattr(err, "errno", None) is not None:
86 | return
87 | status = int(getattr(err, "status", -1))
88 | setattr(err, "errno", _EMAP.get(status, None))
89 |
--------------------------------------------------------------------------------
/setup.cfg:
--------------------------------------------------------------------------------
1 | # Note: I'd prefer that everything here be removed in favor of
2 | # pyproject.toml, but the timing isn't quite right yet for PEP 621 support in
3 | # setuptools so we need to put the values here for now.
4 |
5 | [metadata]
6 | name = sambacc
7 | version = 0.1
8 | description = Samba Container Configurator
9 | author = John Mulligan
10 | author_email = phlogistonjohn@asynchrono.us
11 | readme = file: README.md
12 | url = https://github.com/samba-in-kubernetes/sambacc
13 | license = GPL3
14 | long_description = file: README.md
15 | long_description_content_type = text/markdown
16 |
17 | [options]
18 | packages =
19 | sambacc
20 | sambacc.commands
21 | sambacc.schema
22 | sambacc.grpc
23 | sambacc.grpc.generated
24 | sambacc.commands.remotecontrol
25 | include_package_data = True
26 |
27 | [options.entry_points]
28 | console_scripts =
29 | samba-container = sambacc.commands.main:main
30 | samba-dc-container = sambacc.commands.dcmain:main
31 | samba-remote-control = sambacc.commands.remotecontrol.main:main
32 |
33 | [options.data_files]
34 | share/sambacc/examples =
35 | examples/ctdb.json
36 | examples/example1.json
37 | examples/minimal.json
38 | examples/addc.json
39 |
40 | [options.extras_require]
41 | validation =
42 | jsonschema>=4.10
43 | yaml =
44 | PyYAML>=5.4
45 | toml =
46 | tomli;python_version<"3.11"
47 | rados =
48 | rados
49 | grpc =
50 | grpcio>=1.48
51 | protobuf>=3.19
52 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/samba-in-kubernetes/sambacc/bf6b8c3de914a980056987862fc3a2db55b79c88/tests/__init__.py
--------------------------------------------------------------------------------
/tests/container/Containerfile:
--------------------------------------------------------------------------------
1 | ARG SAMBACC_BASE_IMAGE='registry.fedoraproject.org/fedora:41'
2 | FROM $SAMBACC_BASE_IMAGE
3 |
4 |
5 | COPY build.sh /usr/local/bin/build.sh
6 |
7 | # Set SAMBACC_MINIMAL to yes to build a container that only contains the
8 | # build.sh script on top of the base image. When called, build.sh will
9 | # automatically install the dependency packages. Installing packages on every
10 | # run can be slow, especially if you are hacking on the code or tests, so we
11 | # install those dependencies proactively by default.
12 | ARG SAMBACC_MINIMAL=no
13 | RUN if [ "$SAMBACC_MINIMAL" != "yes" ]; then /usr/local/bin/build.sh --install ; fi
14 | ENTRYPOINT ["/usr/local/bin/build.sh"]
15 |
--------------------------------------------------------------------------------
/tests/test_container_dns.py:
--------------------------------------------------------------------------------
1 | #
2 | # sambacc: a samba container configuration tool
3 | # Copyright (C) 2021 John Mulligan
4 | #
5 | # This program is free software: you can redistribute it and/or modify
6 | # it under the terms of the GNU General Public License as published by
7 | # the Free Software Foundation, either version 3 of the License, or
8 | # (at your option) any later version.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program. If not, see
17 | #
18 |
19 | import io
20 | import time
21 |
22 | import sambacc.container_dns
23 |
24 |
25 | J1 = """
26 | {
27 | "ref": "example",
28 | "items": [
29 | {
30 | "name": "users",
31 | "ipv4": "192.168.76.40",
32 | "target": "external"
33 | },
34 | {
35 | "name": "users-cluster",
36 | "ipv4": "10.235.102.5",
37 | "target": "internal"
38 | }
39 | ]
40 | }
41 | """
42 |
43 | J2 = """
44 | {
45 | "ref": "example2",
46 | "items": [
47 | {
48 | "name": "users-cluster",
49 | "ipv4": "10.235.102.5",
50 | "target": "internal"
51 | }
52 | ]
53 | }
54 | """
55 |
56 | J3 = """
57 | {
58 | "ref": "example",
59 | "items": [
60 | {
61 | "name": "users",
62 | "ipv4": "192.168.76.108",
63 | "target": "external"
64 | },
65 | {
66 | "name": "users-cluster",
67 | "ipv4": "10.235.102.5",
68 | "target": "internal"
69 | }
70 | ]
71 | }
72 | """
73 |
74 |
75 | def test_parse():
76 | fh = io.StringIO(J1)
77 | hs = sambacc.container_dns.parse(fh)
78 | assert hs.ref == "example"
79 | assert len(hs.items) == 2
80 | assert hs.items[0].name == "users"
81 | assert hs.items[0].ipv4_addr == "192.168.76.40"
82 |
83 |
84 | def test_parse2():
85 | fh = io.StringIO(J2)
86 | hs = sambacc.container_dns.parse(fh)
87 | assert hs.ref == "example2"
88 | assert len(hs.items) == 1
89 | assert hs.items[0].name == "users-cluster"
90 | assert hs.items[0].ipv4_addr == "10.235.102.5"
91 |
92 |
93 | def test_same():
94 | hs1 = sambacc.container_dns.HostState(ref="apple")
95 | hs2 = sambacc.container_dns.HostState(ref="orange")
96 | assert hs1 != hs2
97 | hs2 = sambacc.container_dns.HostState(ref="apple")
98 | assert hs1 == hs2
99 | hs1.items = [
100 | sambacc.container_dns.HostInfo("a", "10.10.10.10", "external"),
101 | sambacc.container_dns.HostInfo("b", "10.10.10.11", "external"),
102 | ]
103 | hs2.items = [
104 | sambacc.container_dns.HostInfo("a", "10.10.10.10", "external"),
105 | sambacc.container_dns.HostInfo("b", "10.10.10.11", "external"),
106 | ]
107 | assert hs1 == hs2
108 | hs2.items = [
109 | sambacc.container_dns.HostInfo("a", "10.10.10.10", "external"),
110 | sambacc.container_dns.HostInfo("b", "10.10.10.12", "external"),
111 | ]
112 | assert hs1 != hs2
113 | hs2.items = [
114 | sambacc.container_dns.HostInfo("a", "10.10.10.10", "external")
115 | ]
116 | assert hs1 != hs2
117 |
118 |
119 | def test_register_dummy(capfd):
120 | def register(iconfig, hs):
121 | return sambacc.container_dns.register(
122 | iconfig,
123 | hs,
124 | prefix=["echo"],
125 | )
126 |
127 | hs = sambacc.container_dns.HostState(
128 | ref="example",
129 | items=[
130 | sambacc.container_dns.HostInfo(
131 | "foobar", "10.10.10.10", "external"
132 | ),
133 | sambacc.container_dns.HostInfo(
134 | "blat", "192.168.10.10", "internal"
135 | ),
136 | ],
137 | )
138 | register("example.test", hs)
139 | out, err = capfd.readouterr()
140 | assert "net ads -P dns register foobar.example.test 10.10.10.10" in out
141 |
142 |
143 | def test_parse_and_update(tmp_path):
144 | reg_data = []
145 |
146 | def _register(domain, hs, target_name=""):
147 | reg_data.append((domain, hs))
148 | return True
149 |
150 | path = tmp_path / "test.json"
151 | with open(path, "w") as fh:
152 | fh.write(J1)
153 |
154 | hs1, up = sambacc.container_dns.parse_and_update(
155 | "example.com", path, reg_func=_register
156 | )
157 | assert len(reg_data) == 1
158 | assert up
159 | hs2, up = sambacc.container_dns.parse_and_update(
160 | "example.com", path, previous=hs1, reg_func=_register
161 | )
162 | assert len(reg_data) == 1
163 | assert not up
164 |
165 | with open(path, "w") as fh:
166 | fh.write(J2)
167 | hs3, up = sambacc.container_dns.parse_and_update(
168 | "example.com", path, previous=hs2, reg_func=_register
169 | )
170 | assert len(reg_data) == 2
171 | assert reg_data[-1][1] == hs3
172 | assert up
173 |
174 |
175 | def test_watch(tmp_path):
176 | reg_data = []
177 |
178 | def _register(domain, hs, target_name=""):
179 | reg_data.append((domain, hs))
180 |
181 | def _update(domain, source, previous=None):
182 | return sambacc.container_dns.parse_and_update(
183 | domain, source, previous=previous, reg_func=_register
184 | )
185 |
186 | scount = 0
187 |
188 | def _sleep():
189 | nonlocal scount
190 | scount += 1
191 | if scount > 10:
192 | raise KeyboardInterrupt()
193 | time.sleep(0.05)
194 |
195 | path = tmp_path / "test.json"
196 | with open(path, "w") as fh:
197 | fh.write(J1)
198 |
199 | sambacc.container_dns.watch(
200 | "example.com",
201 | path,
202 | update_func=_update,
203 | pause_func=_sleep,
204 | print_func=lambda x: None,
205 | )
206 | assert scount > 10
207 | assert len(reg_data) == 1
208 |
209 | with open(path, "w") as fh:
210 | fh.write(J1)
211 | scount = 0
212 |
213 | def _sleep2():
214 | nonlocal scount
215 | scount += 1
216 | if scount == 5:
217 | with open(path, "w") as fh:
218 | fh.write(J3)
219 | if scount == 10:
220 | with open(path, "w") as fh:
221 | fh.write(J3)
222 | if scount > 20:
223 | raise KeyboardInterrupt()
224 | time.sleep(0.05)
225 |
226 | sambacc.container_dns.watch(
227 | "example.com",
228 | path,
229 | update_func=_update,
230 | pause_func=_sleep2,
231 | print_func=lambda x: None,
232 | )
233 | assert scount > 20
234 | assert len(reg_data) == 3
235 |
--------------------------------------------------------------------------------
/tests/test_inotify_waiter.py:
--------------------------------------------------------------------------------
1 | #
2 | # sambacc: a samba container configuration tool
3 | # Copyright (C) 2021 John Mulligan
4 | #
5 | # This program is free software: you can redistribute it and/or modify
6 | # it under the terms of the GNU General Public License as published by
7 | # the Free Software Foundation, either version 3 of the License, or
8 | # (at your option) any later version.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program. If not, see
17 | #
18 |
19 | import contextlib
20 | import threading
21 | import time
22 |
23 | import pytest
24 |
25 | try:
26 | import sambacc.inotify_waiter
27 | except ImportError:
28 | pytestmark = pytest.mark.skip
29 |
30 |
31 | @contextlib.contextmanager
32 | def background(bg_func):
33 | t = threading.Thread(target=bg_func)
34 | t.start()
35 | try:
36 | yield None
37 | finally:
38 | t.join()
39 |
40 |
41 | def test_inotify(tmp_path):
42 | tfile = str(tmp_path / "foobar.txt")
43 | tfile2 = str(tmp_path / "other.txt")
44 |
45 | iw = sambacc.inotify_waiter.INotify(tfile, print_func=print, timeout=3)
46 |
47 | def _touch():
48 | time.sleep(0.2)
49 | with open(tfile, "w") as fh:
50 | print("W", tfile)
51 | fh.write("one")
52 |
53 | with background(_touch):
54 | before = time.time()
55 | iw.wait()
56 | after = time.time()
57 | assert after - before > 0.1
58 | assert after - before <= 1
59 |
60 | def _touch2():
61 | time.sleep(0.2)
62 | with open(tfile2, "w") as fh:
63 | print("W", tfile2)
64 | fh.write("two")
65 | time.sleep(1)
66 | with open(tfile, "w") as fh:
67 | print("W", tfile)
68 | fh.write("one")
69 |
70 | with background(_touch2):
71 | before = time.time()
72 | iw.wait()
73 | after = time.time()
74 |
75 | assert after - before > 0.1
76 | assert after - before >= 1
77 |
78 | before = time.time()
79 | iw.wait()
80 | after = time.time()
81 | assert int(after) - int(before) == 3
82 | iw.close()
83 |
84 |
85 | def test_inotify_bad_input():
86 | with pytest.raises(ValueError):
87 | sambacc.inotify_waiter.INotify("/")
88 |
89 |
90 | def test_inotify_relative_path():
91 | iw = sambacc.inotify_waiter.INotify("cool.txt")
92 | assert iw._dir == "."
93 | assert iw._name == "cool.txt"
94 |
--------------------------------------------------------------------------------
/tests/test_jfile.py:
--------------------------------------------------------------------------------
1 | #
2 | # sambacc: a samba container configuration tool
3 | # Copyright (C) 2021 John Mulligan
4 | #
5 | # This program is free software: you can redistribute it and/or modify
6 | # it under the terms of the GNU General Public License as published by
7 | # the Free Software Foundation, either version 3 of the License, or
8 | # (at your option) any later version.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program. If not, see
17 | #
18 |
19 | import pytest
20 |
21 | from sambacc import jfile
22 |
23 |
24 | def test_open(tmpdir):
25 | with pytest.raises(FileNotFoundError):
26 | jfile.open(tmpdir / "a.json", jfile.OPEN_RO)
27 | fh = jfile.open(tmpdir / "a.json", jfile.OPEN_RW)
28 | assert fh is not None
29 | fh.close()
30 |
31 |
32 | def test_laod(tmpdir):
33 | with jfile.open(tmpdir / "a.json", jfile.OPEN_RW) as fh:
34 | data = jfile.load(fh, ["missing"])
35 | assert data == ["missing"]
36 |
37 | with open(tmpdir / "a.json", "w") as fh:
38 | fh.write('{"present": true}\n')
39 | with jfile.open(tmpdir / "a.json", jfile.OPEN_RW) as fh:
40 | data = jfile.load(fh, ["missing"])
41 | assert data == {"present": True}
42 |
43 |
44 | def test_dump(tmpdir):
45 | with jfile.open(tmpdir / "a.json", jfile.OPEN_RW) as fh:
46 | jfile.dump({"something": "good"}, fh)
47 |
48 | with jfile.open(tmpdir / "a.json", jfile.OPEN_RO) as fh:
49 | data = jfile.load(fh)
50 | assert data == {"something": "good"}
51 |
52 | with jfile.open(tmpdir / "a.json", jfile.OPEN_RW) as fh:
53 | jfile.dump({"something": "better"}, fh)
54 |
55 | with jfile.open(tmpdir / "a.json", jfile.OPEN_RO) as fh:
56 | data = jfile.load(fh)
57 | assert data == {"something": "better"}
58 |
59 |
60 | def test_flock(tmpdir):
61 | import time
62 | import threading
63 |
64 | def sleepy_update(path):
65 | with jfile.open(path, jfile.OPEN_RW) as fh:
66 | jfile.flock(fh)
67 | data = jfile.load(fh, [0])
68 | time.sleep(0.2)
69 | data.append(data[-1] + 1)
70 | jfile.dump(data, fh)
71 |
72 | fpath = tmpdir / "a.json"
73 | t1 = threading.Thread(target=sleepy_update, args=(fpath,))
74 | t1.start()
75 | t2 = threading.Thread(target=sleepy_update, args=(fpath,))
76 | t2.start()
77 | t1.join()
78 | t2.join()
79 |
80 | with jfile.open(fpath, jfile.OPEN_RW) as fh:
81 | jfile.flock(fh)
82 | data = jfile.load(fh)
83 | assert data == [0, 1, 2]
84 |
--------------------------------------------------------------------------------
/tests/test_main.py:
--------------------------------------------------------------------------------
1 | #
2 | # sambacc: a samba container configuration tool
3 | # Copyright (C) 2021 John Mulligan
4 | #
5 | # This program is free software: you can redistribute it and/or modify
6 | # it under the terms of the GNU General Public License as published by
7 | # the Free Software Foundation, either version 3 of the License, or
8 | # (at your option) any later version.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program. If not, see
17 | #
18 |
19 | import pytest
20 |
21 | import sambacc.commands.cli
22 | import sambacc.commands.main
23 | from .test_netcmd_loader import config1
24 |
25 |
26 | def run(*args):
27 | return sambacc.commands.main.main(args)
28 |
29 |
30 | def test_no_id(capsys):
31 | with pytest.raises(sambacc.commands.cli.Fail):
32 | run("print-config")
33 |
34 |
35 | def test_print_config(capsys, tmp_path):
36 | fname = tmp_path / "sample.json"
37 | with open(fname, "w") as fh:
38 | fh.write(config1)
39 | run("--identity", "foobar", "--config", str(fname), "print-config")
40 | out, err = capsys.readouterr()
41 | assert "[global]" in out
42 | assert "netbios name = GANDOLPH" in out
43 | assert "[share]" in out
44 | assert "path = /share" in out
45 | assert "[stuff]" in out
46 | assert "path = /mnt/stuff" in out
47 |
48 |
49 | def test_print_config_env_vars(capsys, tmp_path, monkeypatch):
50 | fname = tmp_path / "sample.json"
51 | with open(fname, "w") as fh:
52 | fh.write(config1)
53 | monkeypatch.setenv("SAMBACC_CONFIG", str(fname))
54 | monkeypatch.setenv("SAMBA_CONTAINER_ID", "foobar")
55 | run("print-config")
56 | out, err = capsys.readouterr()
57 | assert "[global]" in out
58 | assert "netbios name = GANDOLPH" in out
59 | assert "[share]" in out
60 | assert "path = /share" in out
61 | assert "[stuff]" in out
62 | assert "path = /mnt/stuff" in out
63 |
--------------------------------------------------------------------------------
/tests/test_netcmd_loader.py:
--------------------------------------------------------------------------------
1 | #
2 | # sambacc: a samba container configuration tool
3 | # Copyright (C) 2021 John Mulligan
4 | #
5 | # This program is free software: you can redistribute it and/or modify
6 | # it under the terms of the GNU General Public License as published by
7 | # the Free Software Foundation, either version 3 of the License, or
8 | # (at your option) any later version.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program. If not, see
17 | #
18 |
19 | import io
20 | import pytest
21 |
22 | import sambacc.config
23 | import sambacc.netcmd_loader
24 | import sambacc.samba_cmds
25 |
26 | smb_conf = """
27 | [global]
28 | cache directory = {path}
29 | state directory = {path}
30 | private dir = {path}
31 | include = registry
32 | """
33 |
34 | config1 = """
35 | {
36 | "samba-container-config": "v0",
37 | "configs": {
38 | "foobar": {
39 | "shares": [
40 | "share",
41 | "stuff"
42 | ],
43 | "globals": ["global0"],
44 | "instance_name": "GANDOLPH"
45 | }
46 | },
47 | "shares": {
48 | "share": {
49 | "options": {
50 | "path": "/share",
51 | "read only": "no",
52 | "valid users": "sambauser",
53 | "guest ok": "no",
54 | "force user": "root"
55 | }
56 | },
57 | "stuff": {
58 | "options": {
59 | "path": "/mnt/stuff"
60 | }
61 | }
62 | },
63 | "globals": {
64 | "global0": {
65 | "options": {
66 | "workgroup": "SAMBA",
67 | "security": "user",
68 | "server min protocol": "SMB2",
69 | "load printers": "no",
70 | "printing": "bsd",
71 | "printcap name": "/dev/null",
72 | "disable spoolss": "yes",
73 | "guest ok": "no"
74 | }
75 | }
76 | },
77 | "_extra_junk": 0
78 | }
79 | """
80 |
81 |
82 | @pytest.fixture(scope="function")
83 | def testloader(tmp_path):
84 | data_path = tmp_path / "_samba"
85 | data_path.mkdir()
86 | smb_conf_path = tmp_path / "smb.conf"
87 | with open(smb_conf_path, "w") as fh:
88 | fh.write(smb_conf.format(path=data_path))
89 |
90 | ldr = sambacc.netcmd_loader.NetCmdLoader()
91 | ldr._net_conf = sambacc.samba_cmds.net[
92 | "--configfile={}".format(smb_conf_path), "conf"
93 | ]
94 | return ldr
95 |
96 |
97 | def test_import(testloader):
98 | fh = io.StringIO(config1)
99 | g = sambacc.config.GlobalConfig(fh)
100 | testloader.import_config(g.get("foobar"))
101 |
102 |
103 | def test_current_shares(testloader):
104 | shares = testloader.current_shares()
105 | assert len(shares) == 0
106 | fh = io.StringIO(config1)
107 | g = sambacc.config.GlobalConfig(fh)
108 | testloader.import_config(g.get("foobar"))
109 | shares = testloader.current_shares()
110 | assert len(shares) == 2
111 | assert "share" in shares
112 | assert "stuff" in shares
113 |
114 |
115 | def test_dump(testloader, tmp_path):
116 | fh = io.StringIO(config1)
117 | g = sambacc.config.GlobalConfig(fh)
118 | testloader.import_config(g.get("foobar"))
119 |
120 | with open(tmp_path / "dump.txt", "w") as fh:
121 | testloader.dump(fh)
122 | with open(tmp_path / "dump.txt") as fh:
123 | dump = fh.read()
124 |
125 | assert "[global]" in dump
126 | assert "netbios name = GANDOLPH" in dump
127 | assert "[share]" in dump
128 | assert "path = /share" in dump
129 | assert "[stuff]" in dump
130 | assert "path = /mnt/stuff" in dump
131 |
132 |
133 | def test_set(testloader, tmp_path):
134 | testloader.set("global", "client signing", "mandatory")
135 |
136 | with open(tmp_path / "dump.txt", "w") as fh:
137 | testloader.dump(fh)
138 | with open(tmp_path / "dump.txt") as fh:
139 | dump = fh.read()
140 |
141 | assert "[global]" in dump
142 | assert "client signing = mandatory" in dump
143 |
144 |
145 | def test_loader_error_set(testloader, tmp_path):
146 | with pytest.raises(sambacc.netcmd_loader.LoaderError):
147 | testloader.set("", "", "yikes")
148 |
--------------------------------------------------------------------------------
/tests/test_passdb_loader.py:
--------------------------------------------------------------------------------
1 | import contextlib
2 | import io
3 | import os
4 | import pytest
5 | import shutil
6 |
7 | import sambacc.passdb_loader
8 | import sambacc.config
9 | from .test_config import config2
10 |
11 | _smb_conf = """
12 | [global]
13 | cache directory = {path}
14 | state directory = {path}
15 | private dir = {path}
16 | include = registry
17 | """
18 |
19 | passwd_append1 = [
20 | "alice:x:1010:1010:ALICE:/home/alice:/bin/bash\n",
21 | "bob:x:1011:1011:BOB:/home/bob:/bin/bash\n",
22 | "carol:x:1010:1010:carol:/home/alice:/bin/bash\n",
23 | ]
24 |
25 |
26 | @pytest.fixture(scope="function")
27 | def smb_conf(tmp_path):
28 | data_path = tmp_path / "_samba"
29 | data_path.mkdir()
30 | smb_conf_path = tmp_path / "smb.conf"
31 | with open(smb_conf_path, "w") as fh:
32 | fh.write(_smb_conf.format(path=data_path))
33 | return smb_conf_path
34 |
35 |
36 | @contextlib.contextmanager
37 | def alter_passwd(path, append):
38 | bkup = path / "passwd.bak"
39 | mypasswd = os.environ.get("NSS_WRAPPER_PASSWD")
40 | shutil.copy(mypasswd, bkup)
41 | with open(mypasswd, "a") as fh:
42 | fh.write("\n")
43 | for line in passwd_append1:
44 | fh.write(line)
45 | yield
46 | shutil.copy(bkup, mypasswd)
47 |
48 |
49 | def requires_passdb_modules():
50 | try:
51 | sambacc.passdb_loader._samba_modules()
52 | except ImportError:
53 | pytest.skip("unable to load samba passdb modules")
54 |
55 |
56 | def test_init_custom_smb_conf(smb_conf):
57 | requires_passdb_modules()
58 | sambacc.passdb_loader.PassDBLoader(smbconf=str(smb_conf))
59 |
60 |
61 | def test_init_default_smb_conf():
62 | requires_passdb_modules()
63 | # this is a bit hacky, but I don't want to assume the local
64 | # system has or doesn't have a "real" smb.conf
65 | if os.path.exists("/etc/samba/smb.conf"):
66 | sambacc.passdb_loader.PassDBLoader(smbconf=None)
67 | else:
68 | with pytest.raises(Exception):
69 | sambacc.passdb_loader.PassDBLoader(smbconf=None)
70 |
71 |
72 | def test_add_users(tmp_path, smb_conf):
73 | requires_passdb_modules()
74 | # TODO: actually use nss_wrapper!
75 | if not os.environ.get("NSS_WRAPPER_PASSWD"):
76 | pytest.skip("need to have path to passwd file")
77 | if os.environ.get("WRITABLE_PASSWD") != "yes":
78 | pytest.skip("need to append users to passwd file")
79 | with alter_passwd(tmp_path, passwd_append1):
80 | fh = io.StringIO(config2)
81 | g = sambacc.config.GlobalConfig(fh)
82 | ic = g.get("foobar")
83 | users = list(ic.users())
84 |
85 | pdbl = sambacc.passdb_loader.PassDBLoader(smbconf=str(smb_conf))
86 | for u in users:
87 | pdbl.add_user(u)
88 |
89 |
90 | def test_add_user_not_in_passwd(smb_conf):
91 | requires_passdb_modules()
92 | pdbl = sambacc.passdb_loader.PassDBLoader(smbconf=str(smb_conf))
93 |
94 | # Irritatingly, the passwd file contents appear to be cached
95 | # so we need to make up a user that is def. not in the etc passwd
96 | # equivalent, in order to get samba libs to reject it
97 | urec = dict(name="nogoodnik", uid=101010, gid=101010, password="yuck")
98 | ubad = sambacc.config.UserEntry(None, urec, 0)
99 | with pytest.raises(Exception):
100 | pdbl.add_user(ubad)
101 |
102 |
103 | def test_add_user_no_passwd(smb_conf):
104 | requires_passdb_modules()
105 | pdbl = sambacc.passdb_loader.PassDBLoader(smbconf=str(smb_conf))
106 |
107 | # Irritatingly, the passwd file contents appear to be cached
108 | # so we need to make up a user that is def. not in the etc passwd
109 | # equivalent, in order to get samba libs to reject it
110 | urec = dict(name="bob", uid=1011, gid=1011)
111 | ubad = sambacc.config.UserEntry(None, urec, 0)
112 | with pytest.raises(ValueError):
113 | pdbl.add_user(ubad)
114 |
--------------------------------------------------------------------------------
/tests/test_passwd_loader.py:
--------------------------------------------------------------------------------
1 | import io
2 |
3 | import sambacc.passwd_loader
4 | from .test_config import config2
5 |
6 | etc_passwd1 = """
7 | root:x:0:0:root:/root:/bin/bash
8 | bin:x:1:1:bin:/bin:/sbin/nologin
9 | daemon:x:2:2:daemon:/sbin:/sbin/nologin
10 | adm:x:3:4:adm:/var/adm:/sbin/nologin
11 | lp:x:4:7:lp:/var/spool/lpd:/sbin/nologin
12 | sync:x:5:0:sync:/sbin:/bin/sync
13 | shutdown:x:6:0:shutdown:/sbin:/sbin/shutdown
14 | halt:x:7:0:halt:/sbin:/sbin/halt
15 | mail:x:8:12:mail:/var/spool/mail:/sbin/nologin
16 | operator:x:11:0:operator:/root:/sbin/nologin
17 | games:x:12:100:games:/usr/games:/sbin/nologin
18 | ftp:x:14:50:FTP User:/var/ftp:/sbin/nologin
19 | nobody:x:65534:65534:Kernel Overflow User:/:/sbin/nologin
20 | dbus:x:81:81:System message bus:/:/sbin/nologin
21 | """.strip()
22 |
23 | etc_group1 = """
24 | root:x:0:
25 | bin:x:1:
26 | daemon:x:2:
27 | sys:x:3:
28 | adm:x:4:
29 | tty:x:5:
30 | disk:x:6:
31 | lp:x:7:
32 | mem:x:8:
33 | kmem:x:9:
34 | wheel:x:10:
35 | cdrom:x:11:
36 | mail:x:12:
37 | man:x:15:
38 | dialout:x:18:
39 | floppy:x:19:
40 | games:x:20:
41 | tape:x:33:
42 | video:x:39:
43 | ftp:x:50:
44 | lock:x:54:
45 | audio:x:63:
46 | users:x:100:
47 | nobody:x:65534:
48 | utmp:x:22:
49 | utempter:x:35:
50 | kvm:x:36:
51 | dbus:x:81:
52 | """.strip()
53 |
54 |
55 | def test_read_existing_passwd():
56 | fh = io.StringIO(etc_passwd1)
57 | pfl = sambacc.passwd_loader.PasswdFileLoader()
58 | pfl.readfp(fh)
59 | assert len(pfl.lines) == 14
60 | assert pfl.lines[0].startswith("root")
61 | fh2 = io.StringIO()
62 | pfl.writefp(fh2)
63 | assert etc_passwd1 == fh2.getvalue()
64 |
65 |
66 | def test_read_existing_group():
67 | fh = io.StringIO(etc_group1)
68 | pfl = sambacc.passwd_loader.GroupFileLoader()
69 | pfl.readfp(fh)
70 | assert len(pfl.lines) == 28
71 | assert pfl.lines[0].startswith("root")
72 | fh2 = io.StringIO()
73 | pfl.writefp(fh2)
74 | assert etc_group1 == fh2.getvalue()
75 |
76 |
77 | def test_add_user():
78 | fh = io.StringIO(config2)
79 | g = sambacc.config.GlobalConfig(fh)
80 | ic = g.get("foobar")
81 | users = list(ic.users())
82 |
83 | pfl = sambacc.passwd_loader.PasswdFileLoader()
84 | for u in users:
85 | pfl.add_user(u)
86 | assert len(pfl.lines) == 3
87 | fh2 = io.StringIO()
88 | pfl.writefp(fh2)
89 | txt = fh2.getvalue()
90 | assert "alice:x:" in txt
91 | assert "bob:x:" in txt
92 | assert "carol:x:" in txt
93 |
94 |
95 | def test_add_group():
96 | fh = io.StringIO(config2)
97 | g = sambacc.config.GlobalConfig(fh)
98 | ic = g.get("foobar")
99 | groups = list(ic.groups())
100 |
101 | gfl = sambacc.passwd_loader.GroupFileLoader()
102 | for g in groups:
103 | gfl.add_group(g)
104 | # test that duplicates don't add extra lines
105 | for g in groups:
106 | gfl.add_group(g)
107 | assert len(gfl.lines) == 3
108 | fh2 = io.StringIO()
109 | gfl.writefp(fh2)
110 | txt = fh2.getvalue()
111 | assert "alice:x:" in txt
112 | assert "bob:x:" in txt
113 | assert "carol:x:" in txt
114 |
115 |
116 | def test_read_passwd_file(tmp_path):
117 | fname = tmp_path / "read_etc_passwd"
118 | with open(fname, "w") as fh:
119 | fh.write(etc_passwd1)
120 | pfl = sambacc.passwd_loader.PasswdFileLoader(fname)
121 | pfl.read()
122 | assert len(pfl.lines) == 14
123 | assert pfl.lines[0].startswith("root")
124 | fh2 = io.StringIO()
125 | pfl.writefp(fh2)
126 | assert etc_passwd1 == fh2.getvalue()
127 |
128 |
129 | def test_write_passwd_file(tmp_path):
130 | fh = io.StringIO(config2)
131 | g = sambacc.config.GlobalConfig(fh)
132 | ic = g.get("foobar")
133 | users = list(ic.users())
134 |
135 | fname = tmp_path / "write_etc_passwd"
136 | with open(fname, "w") as fh:
137 | fh.write(etc_passwd1)
138 |
139 | pfl = sambacc.passwd_loader.PasswdFileLoader(fname)
140 | pfl.read()
141 | for u in users:
142 | pfl.add_user(u)
143 | # test that duplicates don't add extra lines
144 | for u in users:
145 | pfl.add_user(u)
146 | assert len(pfl.lines) == 17
147 | pfl.write()
148 |
149 | with open(fname) as fh:
150 | txt = fh.read()
151 | assert "root:x:" in txt
152 | assert "\nalice:x:" in txt
153 | assert "\nbob:x:" in txt
154 | assert "\ncarol:x:" in txt
155 |
--------------------------------------------------------------------------------
/tests/test_paths.py:
--------------------------------------------------------------------------------
1 | #
2 | # sambacc: a samba container configuration tool
3 | # Copyright (C) 2021 John Mulligan
4 | #
5 | # This program is free software: you can redistribute it and/or modify
6 | # it under the terms of the GNU General Public License as published by
7 | # the Free Software Foundation, either version 3 of the License, or
8 | # (at your option) any later version.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program. If not, see
17 | #
18 |
19 | import os
20 | import pytest
21 |
22 | import sambacc.paths
23 |
24 |
25 | def test_ensure_samba_dirs_fail(tmp_path):
26 | # This is missing both "var/lib" and "run"
27 | with pytest.raises(OSError):
28 | sambacc.paths.ensure_samba_dirs(root=tmp_path)
29 | os.mkdir(tmp_path / "var")
30 | os.mkdir(tmp_path / "var/lib")
31 | # This is missing "run"
32 | with pytest.raises(OSError):
33 | sambacc.paths.ensure_samba_dirs(root=tmp_path)
34 |
35 |
36 | def test_ensure_samba_dirs_ok(tmp_path):
37 | os.mkdir(tmp_path / "var")
38 | os.mkdir(tmp_path / "var/lib")
39 | os.mkdir(tmp_path / "run")
40 | sambacc.paths.ensure_samba_dirs(root=tmp_path)
41 |
42 |
43 | def test_ensure_samba_dirs_already(tmp_path):
44 | os.mkdir(tmp_path / "var")
45 | os.mkdir(tmp_path / "var/lib")
46 | os.mkdir(tmp_path / "var/lib/samba")
47 | os.mkdir(tmp_path / "var/lib/samba/private")
48 | os.mkdir(tmp_path / "run")
49 | os.mkdir(tmp_path / "run/samba/")
50 | os.mkdir(tmp_path / "run/samba/winbindd")
51 | sambacc.paths.ensure_samba_dirs(root=tmp_path)
52 |
53 |
54 | def test_ensure_share_dirs(tmp_path):
55 | assert not os.path.exists(tmp_path / "foobar")
56 | sambacc.paths.ensure_share_dirs("foobar", root=str(tmp_path))
57 | assert os.path.exists(tmp_path / "foobar")
58 |
59 | assert not os.path.exists(tmp_path / "wibble")
60 | sambacc.paths.ensure_share_dirs("/wibble/cat", root=str(tmp_path))
61 | assert os.path.exists(tmp_path / "wibble/cat")
62 | sambacc.paths.ensure_share_dirs("/wibble/cat", root=str(tmp_path))
63 | assert os.path.exists(tmp_path / "wibble/cat")
64 |
--------------------------------------------------------------------------------
/tests/test_permissions.py:
--------------------------------------------------------------------------------
1 | #
2 | # sambacc: a samba container configuration tool
3 | # Copyright (C) 2022 John Mulligan
4 | #
5 | # This program is free software: you can redistribute it and/or modify
6 | # it under the terms of the GNU General Public License as published by
7 | # the Free Software Foundation, either version 3 of the License, or
8 | # (at your option) any later version.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program. If not, see
17 | #
18 |
19 | import os
20 |
21 | import pytest
22 |
23 |
24 | import sambacc.permissions
25 |
26 |
27 | @pytest.mark.parametrize(
28 | "cls",
29 | [
30 | sambacc.permissions.NoopPermsHandler,
31 | sambacc.permissions.InitPosixPermsHandler,
32 | sambacc.permissions.AlwaysPosixPermsHandler,
33 | ],
34 | )
35 | def test_permissions_path(cls):
36 | assert cls("/foo", "user.foo", options={}).path() == "/foo"
37 |
38 |
39 | def test_noop_handler():
40 | nh = sambacc.permissions.NoopPermsHandler("/foo", "user.foo", options={})
41 | assert nh.path() == "/foo"
42 | assert not nh.has_status()
43 | assert nh.status_ok()
44 | assert nh.update() is None
45 |
46 |
47 | @pytest.fixture(scope="function")
48 | def tmp_path_xattrs_ok(tmp_path_factory):
49 | try:
50 | import xattr # type: ignore
51 | except ImportError:
52 | pytest.skip("xattr module not available")
53 |
54 | tmpp = tmp_path_factory.mktemp("needs_xattrs")
55 | try:
56 | xattr.set(str(tmpp), "user.deleteme", "1")
57 | xattr.remove(str(tmpp), "user.deleteme")
58 | except OSError:
59 | raise pytest.skip(
60 | "temp dir does not support xattrs"
61 | " (try changing basetmp to a file system that supports xattrs)"
62 | )
63 | return tmpp
64 |
65 |
66 | def test_init_handler(tmp_path_xattrs_ok):
67 | path = tmp_path_xattrs_ok / "foo"
68 | os.mkdir(path)
69 | ih = sambacc.permissions.InitPosixPermsHandler(
70 | str(path), "user.marker", options={}
71 | )
72 | assert ih.path().endswith("/foo")
73 | assert not ih.has_status()
74 | assert not ih.status_ok()
75 |
76 | ih.update()
77 | assert ih.has_status()
78 | assert ih.status_ok()
79 |
80 | os.chmod(path, 0o755)
81 | ih.update()
82 | assert (os.stat(path).st_mode & 0o777) == 0o755
83 |
84 |
85 | def test_always_handler(tmp_path_xattrs_ok):
86 | path = tmp_path_xattrs_ok / "foo"
87 | os.mkdir(path)
88 | ih = sambacc.permissions.AlwaysPosixPermsHandler(
89 | str(path), "user.marker", options={}
90 | )
91 | assert ih.path().endswith("/foo")
92 | assert not ih.has_status()
93 | assert not ih.status_ok()
94 |
95 | ih.update()
96 | assert ih.has_status()
97 | assert ih.status_ok()
98 |
99 | os.chmod(path, 0o755)
100 | assert (os.stat(path).st_mode & 0o777) == 0o755
101 | ih.update()
102 | assert (os.stat(path).st_mode & 0o777) == 0o777
103 |
--------------------------------------------------------------------------------
/tests/test_samba_cmds.py:
--------------------------------------------------------------------------------
1 | #
2 | # sambacc: a samba container configuration tool
3 | # Copyright (C) 2021 John Mulligan
4 | #
5 | # This program is free software: you can redistribute it and/or modify
6 | # it under the terms of the GNU General Public License as published by
7 | # the Free Software Foundation, either version 3 of the License, or
8 | # (at your option) any later version.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program. If not, see
17 | #
18 |
19 | import sambacc.samba_cmds
20 |
21 |
22 | def test_create_samba_command():
23 | cmd = sambacc.samba_cmds.SambaCommand("hello")
24 | assert cmd.name == "hello"
25 | cmd2 = cmd["world"]
26 | assert cmd.name == "hello"
27 | assert list(cmd) == ["hello"]
28 | assert list(cmd2) == ["hello", "world"]
29 |
30 |
31 | def test_debug_command():
32 | cmd = sambacc.samba_cmds.SambaCommand("beep", debug="5")
33 | assert list(cmd) == ["beep", "--debuglevel=5"]
34 |
35 |
36 | def test_global_debug():
37 | sambacc.samba_cmds.set_global_debug("7")
38 | try:
39 | cmd = sambacc.samba_cmds.SambaCommand("cheep")
40 | assert list(cmd) == ["cheep", "--debuglevel=7"]
41 | finally:
42 | sambacc.samba_cmds.set_global_debug("")
43 |
44 |
45 | def test_global_prefix():
46 | # enabled
47 | sambacc.samba_cmds.set_global_prefix(["bob"])
48 | try:
49 | cmd = sambacc.samba_cmds.SambaCommand("deep")
50 | assert list(cmd) == ["bob", "deep"]
51 | assert cmd.name == "bob"
52 | finally:
53 | sambacc.samba_cmds.set_global_prefix([])
54 |
55 | # disabled
56 | cmd = sambacc.samba_cmds.SambaCommand("deep")
57 | assert list(cmd) == ["deep"]
58 | assert cmd.name == "deep"
59 |
60 |
61 | def test_global_prefix_extended():
62 | # enabled
63 | sambacc.samba_cmds.set_global_prefix(["frank"])
64 | try:
65 | cmd = sambacc.samba_cmds.SambaCommand("deep")[
66 | "13", "--future=not-too-distant"
67 | ]
68 | assert list(cmd) == ["frank", "deep", "13", "--future=not-too-distant"]
69 | assert cmd.name == "frank"
70 | finally:
71 | sambacc.samba_cmds.set_global_prefix([])
72 |
73 | # disabled, must not "inherit" the prefix
74 | cmd2 = cmd["--scheme", "evil"]
75 | assert list(cmd2) == [
76 | "deep",
77 | "13",
78 | "--future=not-too-distant",
79 | "--scheme",
80 | "evil",
81 | ]
82 | assert cmd2.name == "deep"
83 |
84 |
85 | def test_command_repr():
86 | cmd = sambacc.samba_cmds.SambaCommand("doop")
87 | cr = repr(cmd)
88 | assert cr.startswith("SambaCommand")
89 | assert "doop" in cr
90 |
91 |
92 | def test_encode_none():
93 | res = sambacc.samba_cmds.encode(None)
94 | assert res == b""
95 |
96 |
97 | def test_execute():
98 | import os
99 |
100 | cmd = sambacc.samba_cmds.SambaCommand("true")
101 | pid = os.fork()
102 | if pid == 0:
103 | sambacc.samba_cmds.execute(cmd)
104 | else:
105 | _, status = os.waitpid(pid, 0)
106 | assert status == 0
107 |
108 |
109 | def test_create_command_args():
110 | # this is the simpler base class for SambaCommand. It lacks
111 | # the samba debug level option.
112 | cmd = sambacc.samba_cmds.CommandArgs("something")
113 | assert cmd.name == "something"
114 | cmd2 = cmd["nice"]
115 | assert cmd.name == "something"
116 | assert list(cmd) == ["something"]
117 | assert list(cmd2) == ["something", "nice"]
118 |
119 |
120 | def test_command_args_repr():
121 | r = str(sambacc.samba_cmds.CommandArgs("something", ["nice"]))
122 | assert r.startswith("CommandArgs")
123 | assert "something" in r
124 | assert "nice" in r
125 |
126 |
127 | def test_get_samba_specifics(monkeypatch):
128 | monkeypatch.setenv("SAMBA_SPECIFICS", "")
129 | ss = sambacc.samba_cmds.get_samba_specifics()
130 | assert not ss
131 |
132 | monkeypatch.setenv("SAMBA_SPECIFICS", "wibble,quux")
133 | ss = sambacc.samba_cmds.get_samba_specifics()
134 | assert ss
135 | assert len(ss) == 2
136 | assert "wibble" in ss
137 | assert "quux" in ss
138 |
139 |
140 | def test_smbd_foreground(monkeypatch):
141 | monkeypatch.setenv("SAMBA_SPECIFICS", "")
142 | sf = sambacc.samba_cmds.smbd_foreground()
143 | assert "smbd" in sf.name
144 | assert "--log-stdout" in sf.argv()
145 | assert "--debug-stdout" not in sf.argv()
146 |
147 | monkeypatch.setenv("SAMBA_SPECIFICS", "daemon_cli_debug_output")
148 | sf = sambacc.samba_cmds.smbd_foreground()
149 | assert "smbd" in sf.name
150 | assert "--log-stdout" not in sf.argv()
151 | assert "--debug-stdout" in sf.argv()
152 |
153 |
154 | def test_winbindd_foreground(monkeypatch):
155 | monkeypatch.setenv("SAMBA_SPECIFICS", "")
156 | wf = sambacc.samba_cmds.winbindd_foreground()
157 | assert "winbindd" in wf.name
158 | assert "--stdout" in wf.argv()
159 | assert "--debug-stdout" not in wf.argv()
160 |
161 | monkeypatch.setenv("SAMBA_SPECIFICS", "daemon_cli_debug_output")
162 | wf = sambacc.samba_cmds.winbindd_foreground()
163 | assert "winbindd" in wf.name
164 | assert "--stdout" not in wf.argv()
165 | assert "--debug-stdout" in wf.argv()
166 |
--------------------------------------------------------------------------------
/tests/test_simple_waiter.py:
--------------------------------------------------------------------------------
1 | #
2 | # sambacc: a samba container configuration tool
3 | # Copyright (C) 2021 John Mulligan
4 | #
5 | # This program is free software: you can redistribute it and/or modify
6 | # it under the terms of the GNU General Public License as published by
7 | # the Free Software Foundation, either version 3 of the License, or
8 | # (at your option) any later version.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program. If not, see
17 | #
18 |
19 | import sambacc.simple_waiter
20 |
21 |
22 | def test_generate_sleeps():
23 | g = sambacc.simple_waiter.generate_sleeps()
24 | times = [next(g) for _ in range(130)]
25 | assert times[0] == 1
26 | assert times[0:11] == [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
27 | assert times[12] == 5
28 | assert all(times[x] == 5 for x in range(12, 33))
29 | assert times[34] == 60
30 | assert all(times[x] == 60 for x in range(34, 130))
31 |
32 |
33 | def test_sleeper():
34 | def gen():
35 | while True:
36 | yield 8
37 |
38 | cc = 0
39 |
40 | def fake_sleep(v):
41 | nonlocal cc
42 | cc += 1
43 | assert v == 8
44 |
45 | sleeper = sambacc.simple_waiter.Sleeper(times=gen())
46 | sleeper._sleep = fake_sleep
47 | sleeper.wait()
48 | assert cc == 1
49 | for _ in range(3):
50 | sleeper.wait()
51 | assert cc == 4
52 |
53 | cc = 0
54 |
55 | def fake_sleep2(v):
56 | nonlocal cc
57 | cc += 1
58 | assert v == 1
59 |
60 | sleeper = sambacc.simple_waiter.Sleeper()
61 | sleeper._sleep = fake_sleep2
62 | sleeper.wait()
63 | assert cc == 1
64 | for _ in range(3):
65 | sleeper.wait()
66 | assert cc == 4
67 |
--------------------------------------------------------------------------------
/tests/test_skips.py:
--------------------------------------------------------------------------------
1 | #
2 | # sambacc: a samba container configuration tool
3 | # Copyright (C) 2024 John Mulligan
4 | #
5 | # This program is free software: you can redistribute it and/or modify
6 | # it under the terms of the GNU General Public License as published by
7 | # the Free Software Foundation, either version 3 of the License, or
8 | # (at your option) any later version.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program. If not, see
17 | #
18 |
19 | from unittest import mock
20 |
21 | import pytest
22 |
23 | from sambacc.commands import skips
24 |
25 |
26 | @pytest.mark.parametrize(
27 | "value,rtype",
28 | [
29 | ("always:", skips.SkipAlways),
30 | ("file:/var/lib/womble", skips.SkipFile),
31 | ("file:!/var/lib/zomble", skips.SkipFile),
32 | ("env:LIMIT==none", skips.SkipEnv),
33 | ("env:LIMIT!=everybody", skips.SkipEnv),
34 | ("env:LIMIT=everybody", ValueError),
35 | ("env:LIMIT", ValueError),
36 | ("file:", ValueError),
37 | ("always:forever", ValueError),
38 | ("klunk:", KeyError),
39 | ],
40 | )
41 | def test_parse(value, rtype):
42 | if issubclass(rtype, BaseException):
43 | with pytest.raises(rtype):
44 | skips.parse(value)
45 | return
46 | skf = skips.parse(value)
47 | assert isinstance(skf, rtype)
48 |
49 |
50 | @pytest.mark.parametrize(
51 | "value,ret",
52 | [
53 | ("file:/var/lib/foo/a", "skip-if-file-exists: /var/lib/foo/a exists"),
54 | (
55 | "file:!/var/lib/bar/a",
56 | "skip-if-file-missing: /var/lib/bar/a missing",
57 | ),
58 | ("file:/etc/blat", None),
59 | ("env:PLINK==0", "env var: PLINK -> 0 == 0"),
60 | ("env:PLINK!=88", "env var: PLINK -> 0 != 88"),
61 | ("env:PLONK==enabled", None),
62 | ("always:", "always skip"),
63 | ],
64 | )
65 | def test_method_test(value, ret, monkeypatch):
66 | def _exists(p):
67 | rv = p.startswith("/var/lib/foo/")
68 | return rv
69 |
70 | monkeypatch.setattr("os.path.exists", _exists)
71 | monkeypatch.setenv("PLINK", "0")
72 | monkeypatch.setenv("PLONK", "disabled")
73 | skf = skips.parse(value)
74 | ctx = mock.MagicMock()
75 | assert skf.test(ctx) == ret
76 |
77 |
78 | def test_test(monkeypatch):
79 | def _exists(p):
80 | rv = p.startswith("/var/lib/foo/")
81 | return rv
82 |
83 | monkeypatch.setattr("os.path.exists", _exists)
84 | monkeypatch.setenv("PLINK", "0")
85 | monkeypatch.setenv("PLONK", "disabled")
86 |
87 | conds = [
88 | skips.SkipEnv("==", "PLINK", "1"),
89 | skips.SkipEnv("!=", "PLONK", "disabled"),
90 | skips.SkipAlways(),
91 | ]
92 | ctx = mock.MagicMock()
93 | assert skips.test(ctx, conditions=conds) == "always skip"
94 | conds = conds[:-1]
95 | assert not skips.test(ctx, conditions=conds)
96 | monkeypatch.setenv("PLINK", "1")
97 | assert skips.test(ctx, conditions=conds) == "env var: PLINK -> 1 == 1"
98 |
99 | ctx.cli.skip_conditions = conds
100 | assert skips.test(ctx) == "env var: PLINK -> 1 == 1"
101 |
102 |
103 | def test_help_info():
104 | txt = skips._help_info()
105 | assert "file:" in txt
106 | assert "env:" in txt
107 | assert "always:" in txt
108 |
109 |
110 | def test_parse_hack():
111 | import argparse
112 |
113 | with pytest.raises(argparse.ArgumentTypeError):
114 | skips.parse("?")
115 |
--------------------------------------------------------------------------------
/tests/test_smbconf_api.py:
--------------------------------------------------------------------------------
1 | #
2 | # sambacc: a samba container configuration tool
3 | # Copyright (C) 2023 John Mulligan
4 | #
5 | # This program is free software: you can redistribute it and/or modify
6 | # it under the terms of the GNU General Public License as published by
7 | # the Free Software Foundation, either version 3 of the License, or
8 | # (at your option) any later version.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program. If not, see
17 | #
18 |
19 | import io
20 |
21 | import sambacc.smbconf_api
22 |
23 |
24 | def test_simple_config_store():
25 | scs = sambacc.smbconf_api.SimpleConfigStore()
26 | assert scs.writeable, "SimpleConfigStore should always be writeable"
27 | scs["foo"] = [("a", "Artichoke"), ("b", "Broccoli")]
28 | scs["bar"] = [("example", "yes"), ("production", "no")]
29 | assert list(scs) == ["foo", "bar"]
30 | assert scs["foo"] == [("a", "Artichoke"), ("b", "Broccoli")]
31 | assert scs["bar"] == [("example", "yes"), ("production", "no")]
32 |
33 |
34 | def test_simple_config_store_import():
35 | a = sambacc.smbconf_api.SimpleConfigStore()
36 | b = sambacc.smbconf_api.SimpleConfigStore()
37 | a["foo"] = [("a", "Artichoke"), ("b", "Broccoli")]
38 | b["bar"] = [("example", "yes"), ("production", "no")]
39 | assert list(a) == ["foo"]
40 | assert list(b) == ["bar"]
41 |
42 | a.import_smbconf(b)
43 | assert list(a) == ["foo", "bar"]
44 | assert list(b) == ["bar"]
45 | assert a["bar"] == [("example", "yes"), ("production", "no")]
46 |
47 | b["baz"] = [("quest", "one")]
48 | b["bar"] = [("example", "no"), ("production", "no"), ("unittest", "yes")]
49 | a.import_smbconf(b)
50 |
51 | assert list(a) == ["foo", "bar", "baz"]
52 | assert a["bar"] == [
53 | ("example", "no"),
54 | ("production", "no"),
55 | ("unittest", "yes"),
56 | ]
57 | assert a["baz"] == [("quest", "one")]
58 |
59 |
60 | def test_write_store_as_smb_conf():
61 | scs = sambacc.smbconf_api.SimpleConfigStore()
62 | scs["foo"] = [("a", "Artichoke"), ("b", "Broccoli")]
63 | scs["bar"] = [("example", "yes"), ("production", "no")]
64 | scs["global"] = [("first", "1"), ("second", "2")]
65 | fh = io.StringIO()
66 | sambacc.smbconf_api.write_store_as_smb_conf(fh, scs)
67 | res = fh.getvalue().splitlines()
68 | assert res[0] == ""
69 | assert res[1] == "[global]"
70 | assert res[2] == "\tfirst = 1"
71 | assert res[3] == "\tsecond = 2"
72 | assert "[foo]" in res
73 | assert "\ta = Artichoke" in res
74 | assert "\tb = Broccoli" in res
75 | assert "[bar]" in res
76 | assert "\texample = yes" in res
77 | assert "\tproduction = no" in res
78 |
--------------------------------------------------------------------------------
/tests/test_smbconf_samba.py:
--------------------------------------------------------------------------------
1 | #
2 | # sambacc: a samba container configuration tool
3 | # Copyright (C) 2023 John Mulligan
4 | #
5 | # This program is free software: you can redistribute it and/or modify
6 | # it under the terms of the GNU General Public License as published by
7 | # the Free Software Foundation, either version 3 of the License, or
8 | # (at your option) any later version.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program. If not, see
17 | #
18 |
19 | import pytest
20 |
21 | import sambacc.smbconf_api
22 | import sambacc.smbconf_samba
23 |
24 | smb_conf_reg_stub = """
25 | [global]
26 | cache directory = {path}
27 | state directory = {path}
28 | private dir = {path}
29 | include = registry
30 | """
31 |
32 | smb_conf_sample = """
33 | [global]
34 | realm = my.kingdom.fora.horse
35 |
36 | [share_a]
37 | path = /foo/bar/baz
38 | read only = no
39 | [share_b]
40 | path = /foo/x/b
41 | read only = no
42 | [share_c]
43 | path = /foo/x/c
44 | read only = no
45 | [share_d]
46 | path = /foo/x/d
47 | read only = no
48 | [share_e]
49 | path = /foo/x/e
50 | read only = no
51 | """
52 |
53 |
54 | def _import_probe():
55 | try:
56 | import samba.smbconf # type: ignore
57 | import samba.samba3.smbconf # type: ignore # noqa
58 | except ImportError:
59 | pytest.skip("unable to load samba smbconf modules")
60 |
61 |
62 | def _smb_data(path, smb_conf_text):
63 | data_path = path / "_samba"
64 | data_path.mkdir()
65 | smb_conf_path = path / "smb.conf"
66 | smb_conf_path.write_text(smb_conf_text.format(path=data_path))
67 | return smb_conf_path
68 |
69 |
70 | @pytest.fixture(scope="session")
71 | def smbconf_reg_once(tmp_path_factory):
72 | _import_probe()
73 | tmp_path = tmp_path_factory.mktemp("smb_reg")
74 | smb_conf_path = _smb_data(tmp_path, smb_conf_reg_stub)
75 |
76 | return sambacc.smbconf_samba.SMBConf.from_registry(str(smb_conf_path))
77 |
78 |
79 | @pytest.fixture(scope="function")
80 | def smbconf_reg(smbconf_reg_once):
81 | # IMPORTANT: Reminder, samba doesn't release the registry db once opened.
82 | smbconf_reg_once._smbconf.drop()
83 | return smbconf_reg_once
84 |
85 |
86 | @pytest.fixture(scope="function")
87 | def smbconf_file(tmp_path):
88 | _import_probe()
89 | smb_conf_path = _smb_data(tmp_path, smb_conf_sample)
90 |
91 | return sambacc.smbconf_samba.SMBConf.from_file(str(smb_conf_path))
92 |
93 |
94 | def test_smbconf_file_read(smbconf_file):
95 | assert smbconf_file["global"] == [("realm", "my.kingdom.fora.horse")]
96 | assert smbconf_file["share_a"] == [
97 | ("path", "/foo/bar/baz"),
98 | ("read only", "no"),
99 | ]
100 | with pytest.raises(KeyError):
101 | smbconf_file["not_there"]
102 | assert list(smbconf_file) == [
103 | "global",
104 | "share_a",
105 | "share_b",
106 | "share_c",
107 | "share_d",
108 | "share_e",
109 | ]
110 |
111 |
112 | def test_smbconf_write(smbconf_file):
113 | assert not smbconf_file.writeable
114 | with pytest.raises(Exception):
115 | smbconf_file.import_smbconf(sambacc.smbconf_api.SimpleConfigStore())
116 |
117 |
118 | def test_smbconf_reg_write_read(smbconf_reg):
119 | assert smbconf_reg.writeable
120 | assert list(smbconf_reg) == []
121 | smbconf_reg["global"] = [("test:one", "1"), ("test:two", "2")]
122 | assert smbconf_reg["global"] == [("test:one", "1"), ("test:two", "2")]
123 | smbconf_reg["global"] = [("test:one", "1"), ("test:two", "22")]
124 | assert smbconf_reg["global"] == [("test:one", "1"), ("test:two", "22")]
125 |
126 |
127 | def test_smbconf_reg_write_txn_read(smbconf_reg):
128 | assert smbconf_reg.writeable
129 | assert list(smbconf_reg) == []
130 | with smbconf_reg:
131 | smbconf_reg["global"] = [("test:one", "1"), ("test:two", "2")]
132 | assert smbconf_reg["global"] == [("test:one", "1"), ("test:two", "2")]
133 | with smbconf_reg:
134 | smbconf_reg["global"] = [("test:one", "1"), ("test:two", "22")]
135 | assert smbconf_reg["global"] == [("test:one", "1"), ("test:two", "22")]
136 |
137 | # transaction with error
138 | with pytest.raises(ValueError):
139 | with smbconf_reg:
140 | smbconf_reg["global"] = [("test:one", "1"), ("test:two", "2222")]
141 | raise ValueError("foo")
142 | assert smbconf_reg["global"] == [("test:one", "1"), ("test:two", "22")]
143 |
144 | # no transaction with error
145 | with pytest.raises(ValueError):
146 | smbconf_reg["global"] = [("test:one", "1"), ("test:two", "2222")]
147 | raise ValueError("foo")
148 | assert smbconf_reg["global"] == [("test:one", "1"), ("test:two", "2222")]
149 |
150 |
151 | def test_smbconf_reg_import_batched(smbconf_reg, smbconf_file):
152 | assert list(smbconf_reg) == []
153 | smbconf_reg.import_smbconf(smbconf_file, batch_size=4)
154 | assert smbconf_reg["global"] == [("realm", "my.kingdom.fora.horse")]
155 | assert smbconf_reg["share_a"] == [
156 | ("path", "/foo/bar/baz"),
157 | ("read only", "no"),
158 | ]
159 | with pytest.raises(KeyError):
160 | smbconf_reg["not_there"]
161 | assert list(smbconf_reg) == [
162 | "global",
163 | "share_a",
164 | "share_b",
165 | "share_c",
166 | "share_d",
167 | "share_e",
168 | ]
169 |
170 |
171 | def test_smbconf_reg_import_unbatched(smbconf_reg, smbconf_file):
172 | assert list(smbconf_reg) == []
173 | smbconf_reg.import_smbconf(smbconf_file, batch_size=None)
174 | assert smbconf_reg["global"] == [("realm", "my.kingdom.fora.horse")]
175 | assert smbconf_reg["share_a"] == [
176 | ("path", "/foo/bar/baz"),
177 | ("read only", "no"),
178 | ]
179 | with pytest.raises(KeyError):
180 | smbconf_reg["not_there"]
181 | assert list(smbconf_reg) == [
182 | "global",
183 | "share_a",
184 | "share_b",
185 | "share_c",
186 | "share_d",
187 | "share_e",
188 | ]
189 |
--------------------------------------------------------------------------------
/tests/test_url_opener.py:
--------------------------------------------------------------------------------
1 | #
2 | # sambacc: a samba container configuration tool
3 | # Copyright (C) 2023 John Mulligan
4 | #
5 | # This program is free software: you can redistribute it and/or modify
6 | # it under the terms of the GNU General Public License as published by
7 | # the Free Software Foundation, either version 3 of the License, or
8 | # (at your option) any later version.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program. If not, see
17 | #
18 |
19 | import errno
20 | import http
21 | import http.server
22 | import os
23 | import sys
24 | import threading
25 | import urllib.request
26 |
27 | import pytest
28 |
29 | import sambacc.url_opener
30 |
31 |
32 | class _Server:
33 | def __init__(self, port=8111):
34 | port = int(os.environ.get("SAMBACC_TEST_HTTP_PORT", port))
35 | self._port = port
36 | self._server = http.server.HTTPServer(("127.0.0.1", port), _Handler)
37 |
38 | @property
39 | def port(self):
40 | return self._port
41 |
42 | def start(self):
43 | self._t = threading.Thread(target=self._server.serve_forever)
44 | self._t.start()
45 |
46 | def stop(self):
47 | sys.stdout.flush()
48 | self._server.shutdown()
49 | self._t.join()
50 |
51 |
52 | class _Handler(http.server.BaseHTTPRequestHandler):
53 | def do_GET(self):
54 | word = self.path.split("/")[-1]
55 | method = f"get_{word}"
56 | return getattr(self, method)()
57 |
58 | def get_a(self):
59 | return self._ok("Wilbur was Right")
60 |
61 | def get_b(self):
62 | return self._ok("This is a test")
63 |
64 | def get_err404(self):
65 | self._err(http.HTTPStatus.NOT_FOUND, "Not Found")
66 |
67 | def get_err401(self):
68 | self._err(http.HTTPStatus.UNAUTHORIZED, "Unauthorized")
69 |
70 | def get_err403(self):
71 | self._err(http.HTTPStatus.FORBIDDEN, "Forbidden")
72 |
73 | def _ok(self, value):
74 | self.send_response(http.HTTPStatus.OK)
75 | self.send_header("Content-Type", "text/plain")
76 | self.send_header("Content-Length", str(len(value)))
77 | self.end_headers()
78 | self.wfile.write(value.encode("utf8"))
79 |
80 | def _err(self, err_value, err_msg):
81 | self.send_response(err_value)
82 | self.send_header("Content-Type", "text/plain")
83 | self.send_header("Content-Length", str(len(err_msg)))
84 | self.end_headers()
85 | self.wfile.write(err_msg.encode("utf8"))
86 |
87 |
88 | @pytest.fixture(scope="module")
89 | def http_server():
90 | srv = _Server()
91 | srv.start()
92 | try:
93 | yield srv
94 | finally:
95 | srv.stop()
96 |
97 |
98 | def test_success_1(http_server):
99 | url = f"http://localhost:{http_server.port}/a"
100 | opener = sambacc.url_opener.URLOpener()
101 | res = opener.open(url)
102 | assert res.read() == b"Wilbur was Right"
103 |
104 |
105 | def test_success_2(http_server):
106 | url = f"http://localhost:{http_server.port}/b"
107 | opener = sambacc.url_opener.URLOpener()
108 | res = opener.open(url)
109 | assert res.read() == b"This is a test"
110 |
111 |
112 | def test_error_404(http_server):
113 | url = f"http://localhost:{http_server.port}/err404"
114 | opener = sambacc.url_opener.URLOpener()
115 | with pytest.raises(OSError) as err:
116 | opener.open(url)
117 | assert err.value.status == 404
118 | assert err.value.errno == errno.ENOENT
119 |
120 |
121 | def test_error_401(http_server):
122 | url = f"http://localhost:{http_server.port}/err401"
123 | opener = sambacc.url_opener.URLOpener()
124 | with pytest.raises(OSError) as err:
125 | opener.open(url)
126 | assert err.value.status == 401
127 | assert err.value.errno == errno.EPERM
128 |
129 |
130 | def test_error_403(http_server):
131 | url = f"http://localhost:{http_server.port}/err403"
132 | opener = sambacc.url_opener.URLOpener()
133 | with pytest.raises(OSError) as err:
134 | opener.open(url)
135 | assert err.value.status == 403
136 | # No errno mapped for this one
137 |
138 |
139 | def test_map_errno(http_server):
140 | url = f"http://localhost:{http_server.port}/err401"
141 | opener = sambacc.url_opener.URLOpener()
142 | with pytest.raises(OSError) as err:
143 | opener.open(url)
144 | # do not replace an existing errno
145 | err.value.errno = errno.EIO
146 | sambacc.url_opener._map_errno(err.value)
147 | assert err.value.errno == errno.EIO
148 |
149 |
150 | def test_unknown_url():
151 | opener = sambacc.url_opener.URLOpener()
152 | with pytest.raises(sambacc.url_opener.SchemeNotSupported):
153 | opener.open("bloop://foo/bar/baz")
154 |
155 |
156 | def test_unknown_url_type():
157 | opener = sambacc.url_opener.URLOpener()
158 | with pytest.raises(sambacc.url_opener.SchemeNotSupported):
159 | opener.open("bonk-bonk-bonk")
160 |
161 |
162 | def test_value_error_during_handling():
163 | class H(urllib.request.BaseHandler):
164 | def bonk_open(self, req):
165 | raise ValueError("fiddlesticks")
166 |
167 | class UO(sambacc.url_opener.URLOpener):
168 | _handlers = sambacc.url_opener.URLOpener._handlers + [H]
169 |
170 | opener = UO()
171 | with pytest.raises(ValueError) as err:
172 | opener.open("bonk:bonk")
173 | assert str(err.value) == "fiddlesticks"
174 |
--------------------------------------------------------------------------------
/tox.ini:
--------------------------------------------------------------------------------
1 |
2 | [tox]
3 | envlist = flake8, formatting, {py3,py39}-mypy, py3, py39, schemacheck, py3-sys
4 | isolated_build = True
5 |
6 | [testenv]
7 | description = Run unit tests
8 | passenv =
9 | WRITABLE_PASSWD
10 | NSS_WRAPPER_PASSWD
11 | NSS_WRAPPER_GROUP
12 | deps =
13 | pytest
14 | pytest-cov
15 | dnspython
16 | -e .[validation,yaml,toml,grpc]
17 | commands =
18 | py.test -v --cov=sambacc --cov-report=html {posargs:tests}
19 |
20 | [testenv:{py3,py39}-mypy]
21 | description = Run mypy static checker tool
22 | deps =
23 | mypy
24 | types-setuptools
25 | types-pyyaml
26 | types-jsonschema>=4.10
27 | types-protobuf
28 | types-grpcio
29 | tomli
30 | {[testenv]deps}
31 | commands =
32 | mypy sambacc tests
33 |
34 | [testenv:py3-sys]
35 | description = Run unit tests with system packages to validate Samba integration
36 | # py3-sys -- more like sisyphus, am I right?
37 | #
38 | # In order to run tests that rely on "system level" packages (samba,
39 | # xattr, etc.), and not have a lot of test skips, we have to enable the
40 | # sitepackages option. However when it is enabled and you already have a tool
41 | # (mypy, pytest, etc.) installed at the system tox emits a `command found but
42 | # not installed in testenv` warning. We can avoid all those warnings except for
43 | # the 'py3' env by putting all that system enablement stuff only in this
44 | # section.
45 | sitepackages = True
46 | deps =
47 | pytest
48 | pytest-cov
49 | dnspython
50 | inotify_simple
51 | pyxattr
52 | allowlist_externals =
53 | /usr/bin/py.test
54 |
55 | [testenv:formatting]
56 | description = Check the style/formatting for the source files
57 | deps =
58 | black>=24, <25
59 | commands =
60 | black --check -v --extend-exclude sambacc/grpc/generated .
61 |
62 | [testenv:reformat]
63 | description = Reformat the source files using black
64 | deps = {[testenv:formatting]deps}
65 | commands =
66 | black -q --extend-exclude sambacc/grpc/generated .
67 |
68 | [testenv:flake8]
69 | description = Basic python linting for the source files
70 | deps =
71 | flake8
72 | commands =
73 | flake8 --exclude sambacc/grpc/generated sambacc tests
74 |
75 | [testenv:schemacheck]
76 | description = Check the JSON Schema files are valid
77 | deps =
78 | black>=24, <25
79 | PyYAML
80 | commands =
81 | python -m sambacc.schema.tool
82 |
83 | [testenv:schemaupdate]
84 | description = Regenerate source files from JSON Schema file(s)
85 | deps =
86 | black>=24, <25
87 | PyYAML
88 | commands =
89 | python -m sambacc.schema.tool --update
90 |
91 | # this gitlint rule is not run by default.
92 | # Run it manually with: tox -e gitlint
93 | [testenv:gitlint]
94 | description = Check the formatting of Git commit messages
95 | deps =
96 | gitlint==0.19.1
97 | commands =
98 | gitlint -C .gitlint --commits origin/master.. lint
99 |
100 |
101 | # IMPORTANT: note that there are two environments provided here for generating
102 | # the grpc/protobuf files. One uses a typical tox environment with versions
103 | # and the other uses system packages (sitepackages=True).
104 | # The former is what developers are expected to use HOWEVER because we must
105 | # deliver on enterprise linux platforms we provide a way to generate
106 | # the code using system packages for comparison purposes.
107 |
108 | # Generate grpc/protobuf code from .proto files.
109 | # Includes a generator for .pyi files.
110 | # Uses sed to fix the foolish import behavior of the grpc generator.
111 | [testenv:grpc-generate]
112 | description = Generate gRPC files
113 | deps =
114 | grpcio-tools ~= 1.48.0
115 | protobuf ~= 3.19.0
116 | mypy-protobuf
117 | allowlist_externals = sed
118 | commands =
119 | python -m grpc_tools.protoc \
120 | -I sambacc/grpc/protobufs \
121 | --python_out=sambacc/grpc/generated \
122 | --grpc_python_out=sambacc/grpc/generated \
123 | --mypy_out=sambacc/grpc/generated \
124 | sambacc/grpc/protobufs/control.proto
125 | sed -i -E 's/^import.*_pb2/from . \0/' \
126 | sambacc/grpc/generated/control_pb2_grpc.py
127 |
128 | # Generate grpc/protobuf code from .proto files using system packages.
129 | # Does NOT include a generator for .pyi files.
130 | # Uses sed to fix the foolish import behavior of the grpc generator.
131 | [testenv:grpc-sys-generate]
132 | description = Generate gRPC files using system python packages
133 | sitepackages = True
134 | allowlist_externals = sed
135 | commands =
136 | python -m grpc_tools.protoc \
137 | -I sambacc/grpc/protobufs \
138 | --python_out=sambacc/grpc/generated \
139 | --grpc_python_out=sambacc/grpc/generated \
140 | sambacc/grpc/protobufs/control.proto
141 | sed -i -E 's/^import.*_pb2/from . \0/' \
142 | sambacc/grpc/generated/control_pb2_grpc.py
143 |
--------------------------------------------------------------------------------