├── .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 | --------------------------------------------------------------------------------