Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

PTFE-1491 Support vsphere as a backend #549

Merged
merged 28 commits into from
May 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
a49b8db
PTFE-1491 Support vsphere as a backend
tcarmet Mar 8, 2024
1aaa89b
Complete base creation of vm
tcarmet Mar 8, 2024
13d23b7
run trunk format
tcarmet Mar 8, 2024
a760a3a
save state
tcarmet Mar 11, 2024
0cb5044
Set vsphere backend in runer group
tcarmet Mar 11, 2024
4d4051a
modify how client is built
tcarmet Mar 11, 2024
be8a625
Revert the session changes
tcarmet Mar 11, 2024
290f44a
instantiate client
tcarmet Mar 12, 2024
2b641b3
Do not recreate client if not necessary
tcarmet Mar 12, 2024
3645bc2
Functional vm creation
tcarmet Mar 12, 2024
4602d67
commit current work vsphere
tcarmet Mar 18, 2024
dcb7826
Functional state of deploying ovfs
tcarmet Mar 23, 2024
3f89fde
Capability to handle cpu annd memory spec
tcarmet Mar 26, 2024
feb939b
fix delete issue
tcarmet Mar 26, 2024
d2839ee
Add disk provisioning type
tcarmet Mar 26, 2024
2cd4448
lint
tcarmet Mar 26, 2024
0de75be
Focus on specing vm with ovf
tcarmet Mar 29, 2024
68f4cee
add vsphere tests
tcarmet Mar 29, 2024
f78fb12
Merge remote-tracking branch 'origin/main' into feature/PTFE-1491-vsp…
tcarmet Mar 29, 2024
fc89aa4
install pip
tcarmet Mar 29, 2024
a1e22c1
flag to accept
tcarmet Mar 29, 2024
34e51b1
install pipx
tcarmet Mar 29, 2024
4866622
ensure path
tcarmet Mar 30, 2024
fb97158
PTFE-1491 remove unused code
tcarmet Apr 2, 2024
dd0c755
Linting issues
tcarmet Apr 2, 2024
6e26d1c
tweaks and docs
tcarmet Apr 2, 2024
4af1d76
Ensure hostname is defined
tcarmet Apr 2, 2024
62e6a17
Merge remote-tracking branch 'origin/main' into feature/PTFE-1491-vsp…
tcarmet May 14, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/actionlint.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
self-hosted-runner:
labels:
- vsphere
- ubuntu
- jammy
- large
65 changes: 65 additions & 0 deletions .github/workflows/vsphere.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
---
name: vsphere

on:
pull_request:
branches:
- main
paths:
- runner_manager/backend/vsphere.py
push:
branches:
- main
paths:
- runner_manager/backend/vsphere.py
workflow_dispatch: {}

permissions:
contents: read

jobs:
vsphere:
name: vsphere
runs-on:
- self-hosted
- vsphere
- jammy
- large
env:
REDIS_OM_URL: redis://localhost:6379/0
GITHUB_BASE_URL: http://localhost:4010
steps:
- uses: actions/checkout@v4
- name: Boot compose services
run: docker compose --profile tests up --build --detach
- run: |
sudo apt-get install -y pipx
pipx ensurepath
pipx install poetry
- name: ensure .local/bin is in PATH
run: echo "$HOME/.local/bin" >> $GITHUB_PATH
- uses: actions/setup-python@v5
with:
python-version: 3.11
cache: poetry
- run: poetry install
- name: Run tests
run: poetry run pytest tests/unit/backend/test_vsphere.py
env:
GITHUB_TOKEN: test
GOVC_URL: ${{ secrets.GOVC_URL }}
GOVC_USERNAME: ${{ secrets.GOVC_USERNAME }}
GOVC_PASSWORD: ${{ secrets.GOVC_PASSWORD }}
GOVC_INSECURE: true
GOVC_DATACENTER: ${{ secrets.GOVC_DATACENTER }}
GOVC_DATASTORE: ${{ secrets.GOVC_DATASTORE }}
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
flags: vsphere
name: vsphere
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
- name: Dump logs
run: docker compose --profile tests logs
if: always()
1 change: 1 addition & 0 deletions docs/development/concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ for hosting the runners. The following backends will be supported:

- GCP.
- AWS.
- VMware vSphere.
- Docker (For local functional testing).
- FakeBackend (For local unit testing).

Expand Down
1,839 changes: 971 additions & 868 deletions poetry.lock

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ botocore = "^1.31.85"
boto3-stubs = { extras = ["ec2"], version = "^1.34.104" }
githubkit = { extras = ["auth-app"], version = "^0.11.4" }
rq-scheduler = "^0.13.1"
pyvmomi = "^8.0.2.0.1"
vapi-runtime = { url = "https://raw.githubusercontent.com/vmware/vsphere-automation-sdk-python/v8.0.1.0/lib/vapi-runtime/vapi_runtime-2.40.0-py2.py3-none-any.whl" }
vcenter-bindings = { url = "https://raw.githubusercontent.com/vmware/vsphere-automation-sdk-python/v8.0.1.0/lib/vcenter-bindings/vcenter_bindings-4.1.0-py2.py3-none-any.whl" }


[tool.poetry.group.docs]
Expand Down
178 changes: 178 additions & 0 deletions runner_manager/backend/vsphere.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import logging
from base64 import b64encode
from typing import List, Literal

from com.vmware.content.library_client import Item
from com.vmware.content_client import Library
from com.vmware.vcenter.ovf_client import (
DiskProvisioningType,
LibraryItem,
Property,
PropertyParams,
)
from com.vmware.vcenter.vm_client import Power
from com.vmware.vcenter_client import Datacenter, ResourcePool
from pydantic import Field
from requests import Session
from vmware.vapi.vsphere.client import VsphereClient, create_vsphere_client

from runner_manager.backend.base import BaseBackend
from runner_manager.models.backend import Backends, VsphereConfig, VsphereInstanceConfig
from runner_manager.models.runner import Runner

log = logging.getLogger(__name__)


class VsphereBackend(BaseBackend):
name: Literal[Backends.vsphere] = Field(default=Backends.vsphere)
config: VsphereConfig
instance_config: VsphereInstanceConfig

def _create_client(self) -> VsphereClient:
session = Session()
session.verify = self.config.verify_ssl
return create_vsphere_client(
server=self.config.server,
username=self.config.username,
password=self.config.password,
session=session,
)

def get_library_id(self, client: VsphereClient, library: str) -> str:
find_spec = Library.FindSpec(name=library)
library_ids: List[str] = client.content.Library.find(find_spec)
if len(library_ids) == 0:
raise Exception("Library with name '{0}' not found".format(library))
library_id: str = library_ids[0]
return library_id

def get_library_item_id(
self, client: VsphereClient, template: str, library_id: str
):
find_spec = Item.FindSpec(
name=template,
library_id=library_id,
)
item_ids = client.content.library.Item.find(find_spec)
item_id = item_ids[0] if item_ids else None
if item_id:
log.debug("Library item ID: {0}".format(item_id))
else:
raise Exception("Library item with name '{0}' not found".format(template))
return item_id

def get_datacenter(self, client, datacenter_name):
"""
Returns the identifier of a datacenter
Note: The method assumes only one datacenter with the mentioned name.
"""

filter_spec = Datacenter.FilterSpec(names=set([datacenter_name]))

datacenter_summaries = client.vcenter.Datacenter.list(filter_spec)
if len(datacenter_summaries) > 0:
datacenter = datacenter_summaries[0].datacenter
return datacenter
else:
return None

def get_resource_pool(self, client, datacenter_name, resource_pool_name=None):
"""
Returns the identifier of the resource pool with the given name or the
first resource pool in the datacenter if the name is not provided.
"""
datacenter = self.get_datacenter(client, datacenter_name)
if not datacenter:
log.error("Datacenter '{}' not found".format(datacenter_name))
return None

names = set([resource_pool_name]) if resource_pool_name else None
filter_spec = ResourcePool.FilterSpec(
datacenters=set([datacenter]), names=names
)

resource_pool_summaries = client.vcenter.ResourcePool.list(filter_spec)
if len(resource_pool_summaries) > 0:
resource_pool = resource_pool_summaries[0].resource_pool
log.debug("Selecting ResourcePool '{}'".format(resource_pool))
return resource_pool
else:
log.error(
"ResourcePool not found in Datacenter '{}'".format(datacenter_name)
)
return None

def create(self, runner: Runner) -> Runner:
client: VsphereClient = self._create_client()
library_id = self.get_library_id(client, self.instance_config.library)
library_item_id = self.get_library_item_id(
client, self.instance_config.library_item, library_id
)
resource_pool_id = self.get_resource_pool(
client, self.instance_config.datacenter
)
deployment_target = LibraryItem.DeploymentTarget(
resource_pool_id=resource_pool_id,
)

ovf = client.vcenter.ovf.LibraryItem.filter(
library_item_id,
deployment_target,
)
user_data = b64encode(self.instance_config.template_startup(runner).encode())
params = PropertyParams(
properties=[
Property(id="user-data", value=user_data.decode()),
Property(id="hostname", value=runner.name),
Property(id="instance-id", value=runner.name),
],
type="PropertyParams",
)
for param in ovf.additional_params:
if param.to_dict().get("type") == "PropertyParams":
ovf.additional_params.remove(param)
ovf.additional_params.append(params)

deployment_spec = LibraryItem.ResourcePoolDeploymentSpec(
name=runner.name,
annotation=ovf.annotation,
accept_all_eula=True,
network_mappings=None,
storage_mappings=None,
storage_provisioning=DiskProvisioningType(
self.instance_config.disk_provisioning
),
storage_profile_id=None,
locale=None,
flags=None,
additional_parameters=ovf.additional_params,
default_datastore_id=None,
)
deploy = client.vcenter.ovf.LibraryItem.deploy(
library_item_id,
deployment_target,
deployment_spec,
)

log.debug(deploy)
if deploy.succeeded is False:
msg = "Deployment of library item failed"
log.error(msg)
raise Exception(msg)
log.info("Deployment of library item succeeded")
runner.instance_id = deploy.resource_id.id
client.vcenter.vm.Power.start(runner.instance_id)
return super().create(runner)

def delete(self, runner: Runner):
client = self._create_client()
if runner.instance_id is not None:
state = client.vcenter.vm.Power.get(runner.instance_id)
if state == Power.Info(state=Power.State.POWERED_ON):
client.vcenter.vm.Power.stop(runner.instance_id)
elif state == Power.Info(state=Power.State.SUSPENDED):
client.vcenter.vm.Power.start(runner.instance_id)
client.vcenter.vm.Power.stop(runner.instance_id)
log.info(f"Deleting {runner.name}...")
client.vcenter.VM.delete(runner.instance_id)
return super().delete(runner)
13 changes: 13 additions & 0 deletions runner_manager/bin/startup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,18 @@ WantedBy=multi-user.target" | sudo tee /etc/systemd/system/actions.runner.servic

}

function setup_hostname {
# Ensure the hostname is defined in /etc/hosts
local hostname
hostname=$(hostname)

# Check if the hostname is in /etc/hosts
if ! grep -q "${hostname}" /etc/hosts; then
# If it's not in the file, append it
echo "127.0.0.1 ${hostname}" | sudo tee -a /etc/hosts
fi
}

function setup_runner {
sudo groupadd -f docker
sudo useradd -m actions
Expand Down Expand Up @@ -220,4 +232,5 @@ done

init
install_docker
setup_hostname
setup_runner
20 changes: 20 additions & 0 deletions runner_manager/models/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class Backends(str, Enum):
docker = "docker"
gcloud = "gcloud"
aws = "aws"
vsphere = "vsphere"


class BackendConfig(BaseModel):
Expand Down Expand Up @@ -203,3 +204,22 @@ def configure_instance(self, runner: Runner) -> AwsInstance:
BlockDeviceMappings=block_device_mappings,
IamInstanceProfile=iam_instance_profile,
)


class VsphereConfig(BackendConfig):
"""Configuration for vSphere backend."""

server: str
username: str
password: str
verify_ssl: bool = False


class VsphereInstanceConfig(InstanceConfig):
"""Configuration for vSphere backend instance."""

disk_provisioning: Literal["thin", "thick"] = "thin"
datacenter: str
datastore: str
library: str
library_item: str
3 changes: 2 additions & 1 deletion runner_manager/models/runner_group.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from runner_manager.backend.base import BaseBackend
from runner_manager.backend.docker import DockerBackend
from runner_manager.backend.gcloud import GCPBackend
from runner_manager.backend.vsphere import VsphereBackend
from runner_manager.clients.github import GitHub
from runner_manager.clients.github import RunnerGroup as GitHubRunnerGroup
from runner_manager.models.base import BaseModel
Expand Down Expand Up @@ -45,7 +46,7 @@ class BaseRunnerGroup(PydanticBaseModel):
labels: List[str]

backend: Annotated[
Union[BaseBackend, DockerBackend, GCPBackend, AWSBackend],
Union[BaseBackend, DockerBackend, GCPBackend, AWSBackend, VsphereBackend],
PydanticField(..., discriminator="name"),
]

Expand Down
45 changes: 45 additions & 0 deletions tests/unit/backend/test_vsphere.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import os

from pytest import fixture, mark

from runner_manager.backend.vsphere import VsphereBackend
from runner_manager.models.backend import Backends, VsphereConfig, VsphereInstanceConfig
from runner_manager.models.runner import Runner
from runner_manager.models.runner_group import RunnerGroup


@fixture()
def vsphere_group(settings) -> RunnerGroup:
config = VsphereConfig(
server=os.environ.get("GOVC_URL", ""),
username=os.environ.get("GOVC_USERNAME", ""),
password=os.environ.get("GOVC_PASSWORD", ""),
)
runner_group: RunnerGroup = RunnerGroup(
id=2,
name="test",
organization="octo-org",
manager="runner-manager",
backend=VsphereBackend(
name=Backends.vsphere,
config=config,
instance_config=VsphereInstanceConfig(
library="runners",
library_item="jammy-server-cloudimg-amd64",
datacenter=os.environ.get("GOVC_DATACENTER", ""),
datastore=os.environ.get("GOVC_DATASTORE", ""),
),
),
labels=[
"label",
],
)

return runner_group


@mark.skipif(not os.getenv("GOVC_URL"), reason="GOVC_URL environment variable not set")
def test_vsphere_client(vsphere_group: RunnerGroup, runner: Runner):
runner = vsphere_group.backend.create(runner)
assert runner.instance_id is not None
vsphere_group.backend.delete(runner)
Loading
Loading