garm/runner/providers/lxd/lxd.go
Gabriel Adrian Samfira 296333412a
Add enterprise support
This change adds enterprise support throughout garm.

Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>
2022-10-21 17:14:03 +03:00

461 lines
12 KiB
Go

// Copyright 2022 Cloudbase Solutions SRL
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
package lxd
import (
"context"
"fmt"
"sync"
"garm/config"
runnerErrors "garm/errors"
"garm/params"
"garm/runner/common"
"garm/util"
"github.com/google/go-github/v47/github"
lxd "github.com/lxc/lxd/client"
"github.com/lxc/lxd/shared/api"
"github.com/pkg/errors"
)
var _ common.Provider = &LXD{}
const (
// We look for this key in the config of the instances to determine if they are
// created by us or not.
controllerIDKeyName = "user.runner-controller-id"
poolIDKey = "user.runner-pool-id"
)
var (
// lxdToGithubArchMap translates LXD architectures to Github tools architectures.
// TODO: move this in a separate package. This will most likely be used
// by any other provider.
lxdToGithubArchMap map[string]string = map[string]string{
"x86_64": "x64",
"amd64": "x64",
"armv7l": "arm",
"aarch64": "arm64",
"x64": "x64",
"arm": "arm",
"arm64": "arm64",
}
configToLXDArchMap map[config.OSArch]string = map[config.OSArch]string{
config.Amd64: "x86_64",
config.Arm64: "aarch64",
config.Arm: "armv7l",
}
lxdToConfigArch map[string]config.OSArch = map[string]config.OSArch{
"x86_64": config.Amd64,
"aarch64": config.Arm64,
"armv7l": config.Arm,
}
)
const (
DefaultProjectDescription = "This project was created automatically by garm to be used for github ephemeral action runners."
DefaultProjectName = "garm-project"
)
func NewProvider(ctx context.Context, cfg *config.Provider, controllerID string) (common.Provider, error) {
if err := cfg.Validate(); err != nil {
return nil, errors.Wrap(err, "validating provider config")
}
if cfg.ProviderType != config.LXDProvider {
return nil, fmt.Errorf("invalid provider type %s, expected %s", cfg.ProviderType, config.LXDProvider)
}
provider := &LXD{
ctx: ctx,
cfg: cfg,
controllerID: controllerID,
imageManager: &image{
remotes: cfg.LXD.ImageRemotes,
},
}
return provider, nil
}
type LXD struct {
// cfg is the provider config for this provider.
cfg *config.Provider
// ctx is the context.
ctx context.Context
// cli is the LXD client.
cli lxd.InstanceServer
// imageManager downloads images from remotes
imageManager *image
// controllerID is the ID of this controller
controllerID string
mux sync.Mutex
}
func (l *LXD) getCLI() (lxd.InstanceServer, error) {
l.mux.Lock()
defer l.mux.Unlock()
if l.cli != nil {
return l.cli, nil
}
cli, err := getClientFromConfig(l.ctx, &l.cfg.LXD)
if err != nil {
return nil, errors.Wrap(err, "creating LXD client")
}
_, _, err = cli.GetProject(projectName(l.cfg.LXD))
if err != nil {
return nil, errors.Wrapf(err, "fetching project name: %s", projectName(l.cfg.LXD))
}
cli = cli.UseProject(projectName(l.cfg.LXD))
l.cli = cli
return cli, nil
}
func (l *LXD) getProfiles(flavor string) ([]string, error) {
ret := []string{}
if l.cfg.LXD.IncludeDefaultProfile {
ret = append(ret, "default")
}
set := map[string]struct{}{}
cli, err := l.getCLI()
if err != nil {
return nil, errors.Wrap(err, "fetching client")
}
profiles, err := cli.GetProfileNames()
if err != nil {
return nil, errors.Wrap(err, "fetching profile names")
}
for _, profile := range profiles {
set[profile] = struct{}{}
}
if _, ok := set[flavor]; !ok {
return nil, errors.Wrapf(runnerErrors.ErrNotFound, "looking for profile %s", flavor)
}
ret = append(ret, flavor)
return ret, nil
}
func (l *LXD) getTools(image *api.Image, tools []*github.RunnerApplicationDownload) (github.RunnerApplicationDownload, error) {
if image == nil {
return github.RunnerApplicationDownload{}, fmt.Errorf("nil image received")
}
osName, ok := image.ImagePut.Properties["os"]
if !ok {
return github.RunnerApplicationDownload{}, fmt.Errorf("missing OS info in image properties")
}
osType, err := util.OSToOSType(osName)
if err != nil {
return github.RunnerApplicationDownload{}, errors.Wrap(err, "fetching OS type")
}
// Validate image OS. Linux only for now.
switch osType {
case config.Linux:
default:
return github.RunnerApplicationDownload{}, fmt.Errorf("this provider does not support OS type: %s", osType)
}
// Find tools for OS/Arch.
for _, tool := range tools {
if tool == nil {
continue
}
if tool.OS == nil || tool.Architecture == nil {
continue
}
// fmt.Println(*tool.Architecture, *tool.OS)
// fmt.Printf("image arch: %s --> osType: %s\n", image.Architecture, string(osType))
if *tool.Architecture == image.Architecture && *tool.OS == string(osType) {
return *tool, nil
}
arch, ok := lxdToGithubArchMap[image.Architecture]
if ok && arch == *tool.Architecture && *tool.OS == string(osType) {
return *tool, nil
}
}
return github.RunnerApplicationDownload{}, fmt.Errorf("failed to find tools for OS %s and arch %s", osType, image.Architecture)
}
// sadly, the security.secureboot flag is a string encoded boolean.
func (l *LXD) secureBootEnabled() string {
if l.cfg.LXD.SecureBoot {
return "true"
}
return "false"
}
func (l *LXD) getCreateInstanceArgs(bootstrapParams params.BootstrapInstance) (api.InstancesPost, error) {
if bootstrapParams.Name == "" {
return api.InstancesPost{}, runnerErrors.NewBadRequestError("missing name")
}
profiles, err := l.getProfiles(bootstrapParams.Flavor)
if err != nil {
return api.InstancesPost{}, errors.Wrap(err, "fetching profiles")
}
arch, err := resolveArchitecture(bootstrapParams.OSArch)
if err != nil {
return api.InstancesPost{}, errors.Wrap(err, "fetching archictecture")
}
instanceType := l.cfg.LXD.GetInstanceType()
image, err := l.imageManager.EnsureImage(bootstrapParams.Image, instanceType, arch, l.cli)
if err != nil {
return api.InstancesPost{}, errors.Wrap(err, "getting image details")
}
tools, err := l.getTools(image, bootstrapParams.Tools)
if err != nil {
return api.InstancesPost{}, errors.Wrap(err, "getting tools")
}
cloudCfg, err := util.GetCloudConfig(bootstrapParams, tools, bootstrapParams.Name)
if err != nil {
return api.InstancesPost{}, errors.Wrap(err, "generating cloud-config")
}
configMap := map[string]string{
"user.user-data": cloudCfg,
controllerIDKeyName: l.controllerID,
poolIDKey: bootstrapParams.PoolID,
}
if instanceType == config.LXDImageVirtualMachine {
configMap["security.secureboot"] = l.secureBootEnabled()
}
args := api.InstancesPost{
InstancePut: api.InstancePut{
Architecture: image.Architecture,
Profiles: profiles,
Description: "Github runner provisioned by garm",
Config: configMap,
},
Source: api.InstanceSource{
Type: "image",
Fingerprint: image.Fingerprint,
},
Name: bootstrapParams.Name,
Type: api.InstanceType(instanceType),
}
return args, nil
}
func (l *LXD) AsParams() params.Provider {
return params.Provider{
Name: l.cfg.Name,
ProviderType: l.cfg.ProviderType,
Description: l.cfg.Description,
}
}
func (l *LXD) launchInstance(createArgs api.InstancesPost) error {
cli, err := l.getCLI()
if err != nil {
return errors.Wrap(err, "fetching client")
}
// Get LXD to create the instance (background operation)
op, err := cli.CreateInstance(createArgs)
if err != nil {
return errors.Wrap(err, "creating instance")
}
// Wait for the operation to complete
err = op.Wait()
if err != nil {
return errors.Wrap(err, "waiting for instance creation")
}
// Get LXD to start the instance (background operation)
reqState := api.InstanceStatePut{
Action: "start",
Timeout: -1,
}
op, err = cli.UpdateInstanceState(createArgs.Name, reqState, "")
if err != nil {
return errors.Wrap(err, "starting instance")
}
// Wait for the operation to complete
err = op.Wait()
if err != nil {
return errors.Wrap(err, "waiting for instance to start")
}
return nil
}
// CreateInstance creates a new compute instance in the provider.
func (l *LXD) CreateInstance(ctx context.Context, bootstrapParams params.BootstrapInstance) (params.Instance, error) {
args, err := l.getCreateInstanceArgs(bootstrapParams)
if err != nil {
return params.Instance{}, errors.Wrap(err, "fetching create args")
}
if err := l.launchInstance(args); err != nil {
return params.Instance{}, errors.Wrap(err, "creating instance")
}
ret, err := l.waitInstanceHasIP(ctx, args.Name)
if err != nil {
return params.Instance{}, errors.Wrap(err, "fetching instance")
}
return ret, nil
}
// GetInstance will return details about one instance.
func (l *LXD) GetInstance(ctx context.Context, instanceName string) (params.Instance, error) {
cli, err := l.getCLI()
if err != nil {
return params.Instance{}, errors.Wrap(err, "fetching client")
}
instance, _, err := cli.GetInstanceFull(instanceName)
if err != nil {
if isNotFoundError(err) {
return params.Instance{}, errors.Wrapf(runnerErrors.ErrNotFound, "fetching instance: %q", err)
}
return params.Instance{}, errors.Wrap(err, "fetching instance")
}
return lxdInstanceToAPIInstance(instance), nil
}
// Delete instance will delete the instance in a provider.
func (l *LXD) DeleteInstance(ctx context.Context, instance string) error {
cli, err := l.getCLI()
if err != nil {
return errors.Wrap(err, "fetching client")
}
if err := l.setState(instance, "stop", true); err != nil {
if isNotFoundError(err) {
return nil
}
// I am not proud of this, but the drivers.ErrInstanceIsStopped from LXD pulls in
// a ton of CGO, linux specific dependencies, that don't make sense having
// in garm.
if !(errors.Cause(err).Error() == errInstanceIsStopped.Error()) {
return errors.Wrap(err, "stopping instance")
}
}
op, err := cli.DeleteInstance(instance)
if err != nil {
return errors.Wrap(err, "removing instance")
}
err = op.Wait()
if err != nil {
return errors.Wrap(err, "waiting for instance deletion")
}
return nil
}
// ListInstances will list all instances for a provider.
func (l *LXD) ListInstances(ctx context.Context, poolID string) ([]params.Instance, error) {
cli, err := l.getCLI()
if err != nil {
return []params.Instance{}, errors.Wrap(err, "fetching client")
}
instances, err := cli.GetInstancesFull(api.InstanceTypeAny)
if err != nil {
return []params.Instance{}, errors.Wrap(err, "fetching instances")
}
ret := []params.Instance{}
for _, instance := range instances {
if id, ok := instance.ExpandedConfig[controllerIDKeyName]; ok && id == l.controllerID {
if poolID != "" {
id := instance.ExpandedConfig[poolID]
if id != poolID {
// Pool ID was specified. Filter out instances belonging to other pools.
continue
}
}
ret = append(ret, lxdInstanceToAPIInstance(&instance))
}
}
return ret, nil
}
// RemoveAllInstances will remove all instances created by this provider.
func (l *LXD) RemoveAllInstances(ctx context.Context) error {
instances, err := l.ListInstances(ctx, "")
if err != nil {
return errors.Wrap(err, "fetching instance list")
}
for _, instance := range instances {
// TODO: remove in parallel
if err := l.DeleteInstance(ctx, instance.Name); err != nil {
return errors.Wrapf(err, "removing instance %s", instance.Name)
}
}
return nil
}
func (l *LXD) setState(instance, state string, force bool) error {
reqState := api.InstanceStatePut{
Action: state,
Timeout: -1,
Force: force,
}
cli, err := l.getCLI()
if err != nil {
return errors.Wrap(err, "fetching client")
}
op, err := cli.UpdateInstanceState(instance, reqState, "")
if err != nil {
return errors.Wrapf(err, "setting state to %s", state)
}
err = op.Wait()
if err != nil {
return errors.Wrapf(err, "waiting for instance to transition to state %s", state)
}
return nil
}
// Stop shuts down the instance.
func (l *LXD) Stop(ctx context.Context, instance string, force bool) error {
return l.setState(instance, "stop", force)
}
// Start boots up an instance.
func (l *LXD) Start(ctx context.Context, instance string) error {
return l.setState(instance, "start", false)
}