diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..0db3468 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,38 @@ +--- +name: Bug report +about: Create a bug report to help us improve +title: "" +labels: ["bug"] +assignees: '' +--- + +#### Describe the bug + + +#### To Reproduce + + +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +#### Expected behavior + + +#### Screenshots + + +#### Logs + + +#### Environment + +- ROCK version: +- Juju version (output from `juju --version`): +- Cloud Environment: +- Kubernetes version (output from `kubectl version --short`): + +#### Additional context + + diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..fd62eb9 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,11 @@ +# Description + +Please include a summary of the change. Please also include relevant motivation and context. List any dependencies that are required for this change. + +## Checklist + +- [ ] I have performed a self-review of my own code. +- [ ] I have made corresponding changes to the documentation. +- [ ] I have added tests that validate the behaviour of the software. +- [ ] I validated that new and existing tests pass locally with my changes. +- [ ] Any dependent changes have been merged and published in downstream modules. diff --git a/.github/workflows/build-rock.yaml b/.github/workflows/build-rock.yaml new file mode 100644 index 0000000..76c9669 --- /dev/null +++ b/.github/workflows/build-rock.yaml @@ -0,0 +1,17 @@ +name: Build ROCK + +on: + workflow_call: + +jobs: + build-rock: + runs-on: ubuntu-22.04 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - uses: canonical/craft-actions/rockcraft-pack@main + id: rockcraft + - uses: actions/upload-artifact@v3 + with: + name: rock + path: ${{ steps.rockcraft.outputs.rock }} diff --git a/.github/workflows/integration-tests.yaml b/.github/workflows/integration-tests.yaml new file mode 100644 index 0000000..da88d28 --- /dev/null +++ b/.github/workflows/integration-tests.yaml @@ -0,0 +1,32 @@ +name: Integration tests + +on: + workflow_call: + +jobs: + integration-tests: + runs-on: ubuntu-22.04 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - uses: canonical/craft-actions/rockcraft-pack@main + id: rockcraft + + - name: Install Skopeo + run: | + sudo snap install skopeo --edge --devmode + + - name: Import the image to Docker registry + run: | + sudo skopeo --insecure-policy copy oci-archive:${{ steps.rockcraft.outputs.rock }} docker-daemon:vault:1.14.3 + + - name: Integration tests + id: test_image + run: cd tests && tox -e integration + + - uses: actions/upload-artifact@v3 + if: steps.test_image.outcome == 'success' + with: + name: rock + path: ${{ steps.rockcraft.outputs.rock }} diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml new file mode 100644 index 0000000..ee8872a --- /dev/null +++ b/.github/workflows/main.yaml @@ -0,0 +1,24 @@ +name: Main + +on: + push: + schedule: + - cron: '0 8 * * 2' + +jobs: + + build: + uses: ./.github/workflows/build-rock.yaml + + scan: + needs: build-rock + uses: ./.github/workflows/scan-rock.yaml + + integration-tests: + needs: build + uses: ./.github/workflows/integration_tests.yaml + + publish: + if: github.ref_name == 'main' + needs: [scan-rock, integration-tests] + uses: ./.github/workflows/publish-rock.yaml diff --git a/.github/workflows/publish-rock.yaml b/.github/workflows/publish-rock.yaml new file mode 100644 index 0000000..0cb8e07 --- /dev/null +++ b/.github/workflows/publish-rock.yaml @@ -0,0 +1,38 @@ +name: Publish ROCK + +on: + workflow_call: + +jobs: + + publish-rock: + runs-on: ubuntu-22.04 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Log in to the Container registry + uses: docker/login-action@v3.0.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Install skopeo + run: | + sudo snap install --devmode --channel edge skopeo + - uses: actions/download-artifact@v3 + with: + name: rock + + - name: Import and push to github package + run: | + image_name="$(yq '.name' rockcraft.yaml)" + version="$(yq '.version' rockcraft.yaml)" + rock_file=$(ls *.rock | tail -n 1) + sudo skopeo \ + --insecure-policy \ + copy \ + oci-archive:"${rock_file}" \ + docker-daemon:"ghcr.io/canonical/${image_name}:${version}" + docker push ghcr.io/canonical/${image_name}:${version} diff --git a/.github/workflows/scan-rock.yaml b/.github/workflows/scan-rock.yaml new file mode 100644 index 0000000..81695c4 --- /dev/null +++ b/.github/workflows/scan-rock.yaml @@ -0,0 +1,49 @@ +name: Scan + +on: + workflow_call: + +jobs: + + scan: + runs-on: ubuntu-22.04 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install skopeo + run: | + sudo snap install --devmode --channel edge skopeo + + - name: Install yq + run: | + sudo snap install yq + + - uses: actions/download-artifact@v3 + with: + name: rock + + - name: Import + run: | + image_name="$(yq '.name' rockcraft.yaml)" + echo "image_name=${image_name}" >> $GITHUB_ENV + version="$(yq '.version' rockcraft.yaml)" + echo "version=${version}" >> $GITHUB_ENV + rock_file=$(ls *.rock | tail -n 1) + sudo skopeo \ + --insecure-policy \ + copy \ + oci-archive:"${rock_file}" \ + docker-daemon:"ghcr.io/canonical/${image_name}:${version}" + + - name: Run Trivy vulnerability scanner + uses: aquasecurity/trivy-action@master + with: + image-ref: "ghcr.io/canonical/${{env.image_name}}:${{env.version}}" + format: 'sarif' + output: 'trivy-results.sarif' + + - name: Upload Trivy scan results to GitHub Security tab + uses: github/codeql-action/upload-sarif@v2 + with: + sarif_file: 'trivy-results.sarif' diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000..bbe77cb --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1 @@ +* @canonical/telco diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..0137d36 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,9 @@ +# Contributing + +## Build and deploy + +```bash +rockcraft pack -v +sudo skopeo --insecure-policy copy oci-archive:vault_1.14.3_amd64.rock docker-daemon:vault:1.14.3 +docker run sdcore-amf:1.3 +``` diff --git a/README.md b/README.md index 11a9261..8a02d39 100644 --- a/README.md +++ b/README.md @@ -1 +1,10 @@ -# vault-rock \ No newline at end of file +# vault-rock + +A ROCK image for Vault. + +## Usage + +```console +docker pull ghcr.io/canonical/vault:1.14.3 +docker run -it ghcr.io/canonical/vault:1.14.3 +``` diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..c8dd3c6 --- /dev/null +++ b/renovate.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:base" + ], + "packageRules": [ + { + "matchUpdateTypes": ["minor", "patch"], + "automerge": true + } + ], + "platformAutomerge": true +} diff --git a/rockcraft.yaml b/rockcraft.yaml new file mode 100644 index 0000000..b565708 --- /dev/null +++ b/rockcraft.yaml @@ -0,0 +1,20 @@ +name: vault +base: ubuntu:22.04 +build-base: ubuntu:22.04 +version: "1.14.3" +summary: A ROCK container image for Vault +description: | + A ROCK container image for Vault, a tool for secrets management, encryption as a service, and privileged access management. +license: Apache-2.0 +platforms: + amd64: + +parts: + + vault: + plugin: go + source: https://github.com/hashicorp/vault.git + source-tag: v1.14.3 + source-type: git + build-snaps: + - go/1.20/stable diff --git a/tests/config/config.hcl b/tests/config/config.hcl new file mode 100644 index 0000000..673d95a --- /dev/null +++ b/tests/config/config.hcl @@ -0,0 +1,13 @@ +ui = true +cluster_addr = "https://127.0.0.1:8201" +api_addr = "https://127.0.0.1:8200" +disable_mlock = true + +storage "file" { + path = "/vault/data" +} + +listener "tcp" { + address = "[::]:8200" + tls_disable = true +} diff --git a/tests/pyproject.toml b/tests/pyproject.toml new file mode 100644 index 0000000..e81fbc1 --- /dev/null +++ b/tests/pyproject.toml @@ -0,0 +1,52 @@ +[tool.coverage.run] +branch = true + +[tool.coverage.report] +show_missing = true + +[tool.black] +line-length = 99 +target-version = ["py38"] + +[tool.isort] +profile = "black" + +[tool.flake8] +max-line-length = 99 +max-doc-length = 99 +max-complexity = 10 +exclude = [".git", "__pycache__", ".tox", "build", "dist", "*.egg_info", "venv"] +select = ["E", "W", "F", "C", "N", "R", "D", "H"] +per-file-ignores = ["tests/*:D100,D101,D102,D103,D107"] +docstring-convention = "google" +copyright-check = "True" +copyright-author = "Canonical Ltd." +copyright-regexp = "Copyright\\s\\d{4}([-,]\\d{4})*\\s+%(author)s" + +[tool.mypy] +pretty = true +python_version = 3.8 +mypy_path = "$MYPY_CONFIG_FILE_DIR/src:$MYPY_CONFIG_FILE_DIR/lib:$MYPY_CONFIG_FILE_DIR/tests/unit" +follow_imports = "normal" +warn_redundant_casts = true +warn_unused_ignores = true +warn_unused_configs = true +show_traceback = true +show_error_codes = true +namespace_packages = true +explicit_package_bases = true +check_untyped_defs = true +allow_redefinition = true + +# Ignore libraries that do not have type hint nor stubs +[[tool.mypy.overrides]] +module = ["ops.*", "kubernetes.*", "flatten_json.*", "git.*"] +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = ["charms.*"] +follow_imports = "silent" + +[tool.pytest.ini_options] +minversion = "6.0" +log_cli_level = "INFO" diff --git a/tests/requirements.txt b/tests/requirements.txt new file mode 100644 index 0000000..440fb85 --- /dev/null +++ b/tests/requirements.txt @@ -0,0 +1,2 @@ +hvac +requests diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..389aa57 --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Integration tests to validate that the Vault ROCK works as expected.""" + +import logging +import subprocess +import time +import unittest + +from vault import Vault + +logger = logging.getLogger(__name__) + + +def wait_for_vault_to_be_available(timeout: int = 30): + """Wait for Vault to be available.""" + initial_time = time.time() + vault = Vault("http://localhost:8200") + while time.time() - initial_time < timeout: + if vault.is_api_available(): + logger.info("Vault API is available") + return True + else: + time.sleep(1) + raise TimeoutError("Vault is not available after {} seconds".format(timeout)) + + +class TestVaultRock(unittest.TestCase): + """Integration tests to validate that the Vault ROCK works as expected.""" + + def setUp(self): + """Starts a Vault container.""" + subprocess.check_call( + "docker run -d -p 8200:8200 -v ${PWD}/config:/vault/config --entrypoint /bin/bash vault:1.14.3 -c 'vault server -config=/vault/config/config.hcl'", # noqa: E501 + shell=True, + ) + + def test_given_vault_container_running_when_initialize_then_properly_responds_to_commands( + self, + ): + """Runs basic CLI commands to initialize and unseal Vault.""" + vault = Vault("http://localhost:8200") + + wait_for_vault_to_be_available() + + root_token, unseal_keys = vault.initialize() + + vault.unseal(unseal_keys) + + self.assertEqual(vault.is_initialized(), True) + self.assertEqual(vault.is_sealed(), False) diff --git a/tests/tox.ini b/tests/tox.ini new file mode 100644 index 0000000..6b54cc8 --- /dev/null +++ b/tests/tox.ini @@ -0,0 +1,65 @@ +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. + +[tox] +skipsdist=True +skip_missing_interpreters = True +envlist = integration + + +[testenv] +deps = + pytest + pytest-operator +setenv = + PYTHONPATH = {toxinidir} + PYTHONBREAKPOINT=ipdb.set_trace +; +; [testenv:fmt] +; description = Apply coding style standards to code +; deps = +; black +; isort +; commands = +; isort {[vars]integration_test_path} +; black {[vars]integration_test_path} + +[testenv:lint] +description = Check code against coding style standards +deps = + black + flake8 == 4.0.1 + flake8-docstrings + flake8-copyright + flake8-builtins + pyproject-flake8 + pep8-naming + isort +commands = + pflake8 {toxinidir} + isort --check-only --diff {toxinidir} + black --check --diff {toxinidir} + +[testenv:static] +description = Run static analysis checks +deps = + -r{toxinidir}/requirements.txt + mypy + types-PyYAML + pytest + pytest-operator + juju + types-setuptools + types-toml +commands = + mypy {toxinidir} {posargs} +setenv = + PYTHONPATH = "" + +[testenv:integration] +description = Run integration tests +deps = + pytest + -r{toxinidir}/requirements.txt +commands = + pytest -v --tb native --log-cli-level=INFO -s {posargs} {toxinidir} \ No newline at end of file diff --git a/tests/vault.py b/tests/vault.py new file mode 100644 index 0000000..5cfe6ee --- /dev/null +++ b/tests/vault.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Contains all the specificities to communicate with Vault through its API.""" + +import logging +from typing import List, Tuple + +import hvac # type: ignore[import] +import requests # type: ignore[import] + +logger = logging.getLogger(__name__) + + +class Vault: + """Class to interact with Vault through its API.""" + + def __init__(self, url: str): + """Initialize Vault CLI client.""" + self._client = hvac.Client(url=url, verify=False) + + def initialize( + self, secret_shares: int = 1, secret_threshold: int = 1 + ) -> Tuple[str, List[str]]: + """Initialize Vault. + + Returns: + A tuple containing the root token and the unseal keys. + """ + initialize_response = self._client.sys.initialize( + secret_shares=secret_shares, secret_threshold=secret_threshold + ) + logger.info("Vault is initialized") + return initialize_response["root_token"], initialize_response["keys"] + + def is_initialized(self) -> bool: + """Returns whether Vault is initialized.""" + return self._client.sys.is_initialized() + + def is_sealed(self) -> bool: + """Returns whether Vault is sealed.""" + return self._client.sys.is_sealed() + + def unseal(self, unseal_keys: List[str]) -> None: + """Unseal Vault.""" + for unseal_key in unseal_keys: + self._client.sys.submit_unseal_key(unseal_key) + logger.info("Vault is unsealed") + + def is_api_available(self) -> bool: + """Returns whether Vault is available.""" + self._client.sys.read_health_status() + try: + self._client.sys.read_health_status() + except requests.exceptions.ConnectionError: + return False + return True + + def set_token(self, token: str) -> None: + """Sets the Vault token for authentication.""" + self._client.token = token