Restructure project
We previously didn't really had any structure in our project apart from creating a new folder for each package in our project root. Now that we have accumulated some packages, we use the well-known Golang project layout in order to clearly communicate our intent with packages. See https://github.com/golang-standards/project-layout
This commit is contained in:
132
internal/environment/manager.go
Normal file
132
internal/environment/manager.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package environment
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
nomadApi "github.com/hashicorp/nomad/api"
|
||||
"github.com/hashicorp/nomad/jobspec2"
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
"gitlab.hpi.de/codeocean/codemoon/poseidon/internal/nomad"
|
||||
"gitlab.hpi.de/codeocean/codemoon/poseidon/internal/runner"
|
||||
"gitlab.hpi.de/codeocean/codemoon/poseidon/pkg/dto"
|
||||
"gitlab.hpi.de/codeocean/codemoon/poseidon/pkg/logging"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// templateEnvironmentJobHCL holds our default job in HCL format.
|
||||
// The default job is used when creating new job and provides
|
||||
// common settings that all the jobs share.
|
||||
//go:embed template-environment-job.hcl
|
||||
var templateEnvironmentJobHCL string
|
||||
|
||||
var log = logging.GetLogger("environment")
|
||||
|
||||
// Manager encapsulates API calls to the executor API for creation and deletion of execution environments.
|
||||
type Manager interface {
|
||||
// Load fetches all already created execution environments from the executor and registers them at the runner manager.
|
||||
// It should be called during the startup process (e.g. on creation of the Manager).
|
||||
Load() error
|
||||
|
||||
// CreateOrUpdate creates/updates an execution environment on the executor.
|
||||
// If the job was created, the returned boolean is true, if it was updated, it is false.
|
||||
// If err is not nil, that means the environment was neither created nor updated.
|
||||
CreateOrUpdate(
|
||||
id runner.EnvironmentID,
|
||||
request dto.ExecutionEnvironmentRequest,
|
||||
) (bool, error)
|
||||
}
|
||||
|
||||
func NewNomadEnvironmentManager(
|
||||
runnerManager runner.Manager,
|
||||
apiClient nomad.ExecutorAPI,
|
||||
) *NomadEnvironmentManager {
|
||||
m := &NomadEnvironmentManager{runnerManager, apiClient, *parseJob(templateEnvironmentJobHCL)}
|
||||
if err := m.Load(); err != nil {
|
||||
log.WithError(err).Error("Error recovering the execution environments")
|
||||
}
|
||||
runnerManager.Load()
|
||||
return m
|
||||
}
|
||||
|
||||
type NomadEnvironmentManager struct {
|
||||
runnerManager runner.Manager
|
||||
api nomad.ExecutorAPI
|
||||
templateEnvironmentJob nomadApi.Job
|
||||
}
|
||||
|
||||
func (m *NomadEnvironmentManager) CreateOrUpdate(
|
||||
id runner.EnvironmentID,
|
||||
request dto.ExecutionEnvironmentRequest,
|
||||
) (bool, error) {
|
||||
templateJob, err := m.api.RegisterTemplateJob(&m.templateEnvironmentJob, runner.TemplateJobID(id),
|
||||
request.PrewarmingPoolSize, request.CPULimit, request.MemoryLimit,
|
||||
request.Image, request.NetworkAccess, request.ExposedPorts)
|
||||
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("error registering template job in API: %w", err)
|
||||
}
|
||||
|
||||
created, err := m.runnerManager.CreateOrUpdateEnvironment(id, request.PrewarmingPoolSize, templateJob, true)
|
||||
if err != nil {
|
||||
return created, fmt.Errorf("error updating environment in runner manager: %w", err)
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func (m *NomadEnvironmentManager) Load() error {
|
||||
templateJobs, err := m.api.LoadEnvironmentJobs()
|
||||
if err != nil {
|
||||
return fmt.Errorf("couldn't load template jobs: %w", err)
|
||||
}
|
||||
|
||||
for _, job := range templateJobs {
|
||||
jobLogger := log.WithField("jobID", *job.ID)
|
||||
if *job.Status != structs.JobStatusRunning {
|
||||
jobLogger.Info("Job not running, skipping ...")
|
||||
continue
|
||||
}
|
||||
configTaskGroup := nomad.FindConfigTaskGroup(job)
|
||||
if configTaskGroup == nil {
|
||||
jobLogger.Info("Couldn't find config task group in job, skipping ...")
|
||||
continue
|
||||
}
|
||||
desiredIdleRunnersCount, err := strconv.Atoi(configTaskGroup.Meta[nomad.ConfigMetaPoolSizeKey])
|
||||
if err != nil {
|
||||
jobLogger.Infof("Couldn't convert pool size to int: %v, skipping ...", err)
|
||||
continue
|
||||
}
|
||||
environmentIDString, err := runner.EnvironmentIDFromTemplateJobID(*job.ID)
|
||||
if err != nil {
|
||||
jobLogger.WithError(err).Error("Couldn't retrieve environment id from template job")
|
||||
}
|
||||
environmentID, err := runner.NewEnvironmentID(environmentIDString)
|
||||
if err != nil {
|
||||
jobLogger.WithField("environmentID", environmentIDString).
|
||||
WithError(err).
|
||||
Error("Couldn't retrieve environmentID from string")
|
||||
continue
|
||||
}
|
||||
_, err = m.runnerManager.CreateOrUpdateEnvironment(environmentID, uint(desiredIdleRunnersCount), job, false)
|
||||
if err != nil {
|
||||
jobLogger.WithError(err).Info("Could not recover job.")
|
||||
continue
|
||||
}
|
||||
jobLogger.Info("Successfully recovered environment")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseJob(jobHCL string) *nomadApi.Job {
|
||||
config := jobspec2.ParseConfig{
|
||||
Body: []byte(jobHCL),
|
||||
AllowFS: false,
|
||||
Strict: true,
|
||||
}
|
||||
job, err := jobspec2.ParseWithConfig(&config)
|
||||
if err != nil {
|
||||
log.WithError(err).Fatal("Error parsing Nomad job")
|
||||
return nil
|
||||
}
|
||||
|
||||
return job
|
||||
}
|
55
internal/environment/manager_mock.go
Normal file
55
internal/environment/manager_mock.go
Normal file
@@ -0,0 +1,55 @@
|
||||
// Code generated by mockery v2.8.0. DO NOT EDIT.
|
||||
|
||||
package environment
|
||||
|
||||
import (
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
dto "gitlab.hpi.de/codeocean/codemoon/poseidon/pkg/dto"
|
||||
|
||||
runner "gitlab.hpi.de/codeocean/codemoon/poseidon/internal/runner"
|
||||
)
|
||||
|
||||
// ManagerMock is an autogenerated mock type for the Manager type
|
||||
type ManagerMock struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
// CreateOrUpdate provides a mock function with given fields: id, request
|
||||
func (_m *ManagerMock) CreateOrUpdate(id runner.EnvironmentID, request dto.ExecutionEnvironmentRequest) (bool, error) {
|
||||
ret := _m.Called(id, request)
|
||||
|
||||
var r0 bool
|
||||
if rf, ok := ret.Get(0).(func(runner.EnvironmentID, dto.ExecutionEnvironmentRequest) bool); ok {
|
||||
r0 = rf(id, request)
|
||||
} else {
|
||||
r0 = ret.Get(0).(bool)
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(runner.EnvironmentID, dto.ExecutionEnvironmentRequest) error); ok {
|
||||
r1 = rf(id, request)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// Delete provides a mock function with given fields: id
|
||||
func (_m *ManagerMock) Delete(id string) {
|
||||
_m.Called(id)
|
||||
}
|
||||
|
||||
// Load provides a mock function with given fields:
|
||||
func (_m *ManagerMock) Load() error {
|
||||
ret := _m.Called()
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func() error); ok {
|
||||
r0 = rf()
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
142
internal/environment/manager_test.go
Normal file
142
internal/environment/manager_test.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package environment
|
||||
|
||||
import (
|
||||
nomadApi "github.com/hashicorp/nomad/api"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/sirupsen/logrus/hooks/test"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/suite"
|
||||
"gitlab.hpi.de/codeocean/codemoon/poseidon/internal/nomad"
|
||||
"gitlab.hpi.de/codeocean/codemoon/poseidon/internal/runner"
|
||||
"gitlab.hpi.de/codeocean/codemoon/poseidon/pkg/dto"
|
||||
"gitlab.hpi.de/codeocean/codemoon/poseidon/tests"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type CreateOrUpdateTestSuite struct {
|
||||
suite.Suite
|
||||
runnerManagerMock runner.ManagerMock
|
||||
apiMock nomad.ExecutorAPIMock
|
||||
request dto.ExecutionEnvironmentRequest
|
||||
manager *NomadEnvironmentManager
|
||||
environmentID runner.EnvironmentID
|
||||
}
|
||||
|
||||
func TestCreateOrUpdateTestSuite(t *testing.T) {
|
||||
suite.Run(t, new(CreateOrUpdateTestSuite))
|
||||
}
|
||||
|
||||
func (s *CreateOrUpdateTestSuite) SetupTest() {
|
||||
s.runnerManagerMock = runner.ManagerMock{}
|
||||
|
||||
s.apiMock = nomad.ExecutorAPIMock{}
|
||||
s.request = dto.ExecutionEnvironmentRequest{
|
||||
PrewarmingPoolSize: 10,
|
||||
CPULimit: 20,
|
||||
MemoryLimit: 30,
|
||||
Image: "my-image",
|
||||
NetworkAccess: false,
|
||||
ExposedPorts: nil,
|
||||
}
|
||||
|
||||
s.manager = &NomadEnvironmentManager{
|
||||
runnerManager: &s.runnerManagerMock,
|
||||
api: &s.apiMock,
|
||||
}
|
||||
|
||||
s.environmentID = runner.EnvironmentID(tests.DefaultEnvironmentIDAsInteger)
|
||||
}
|
||||
|
||||
func (s *CreateOrUpdateTestSuite) mockRegisterTemplateJob(job *nomadApi.Job, err error) {
|
||||
s.apiMock.On("RegisterTemplateJob",
|
||||
mock.AnythingOfType("*api.Job"), mock.AnythingOfType("string"),
|
||||
mock.AnythingOfType("uint"), mock.AnythingOfType("uint"), mock.AnythingOfType("uint"),
|
||||
mock.AnythingOfType("string"), mock.AnythingOfType("bool"), mock.AnythingOfType("[]uint16")).
|
||||
Return(job, err)
|
||||
}
|
||||
|
||||
func (s *CreateOrUpdateTestSuite) mockCreateOrUpdateEnvironment(created bool, err error) {
|
||||
s.runnerManagerMock.On("CreateOrUpdateEnvironment", mock.AnythingOfType("EnvironmentID"),
|
||||
mock.AnythingOfType("uint"), mock.AnythingOfType("*api.Job"), mock.AnythingOfType("bool")).
|
||||
Return(created, err)
|
||||
}
|
||||
|
||||
func (s *CreateOrUpdateTestSuite) TestRegistersCorrectTemplateJob() {
|
||||
s.mockRegisterTemplateJob(&nomadApi.Job{}, nil)
|
||||
s.mockCreateOrUpdateEnvironment(true, nil)
|
||||
|
||||
_, err := s.manager.CreateOrUpdate(s.environmentID, s.request)
|
||||
s.NoError(err)
|
||||
|
||||
s.apiMock.AssertCalled(s.T(), "RegisterTemplateJob",
|
||||
&s.manager.templateEnvironmentJob, runner.TemplateJobID(s.environmentID),
|
||||
s.request.PrewarmingPoolSize, s.request.CPULimit, s.request.MemoryLimit,
|
||||
s.request.Image, s.request.NetworkAccess, s.request.ExposedPorts)
|
||||
}
|
||||
|
||||
func (s *CreateOrUpdateTestSuite) TestReturnsErrorWhenRegisterTemplateJobReturnsError() {
|
||||
s.mockRegisterTemplateJob(nil, tests.ErrDefault)
|
||||
|
||||
created, err := s.manager.CreateOrUpdate(s.environmentID, s.request)
|
||||
s.ErrorIs(err, tests.ErrDefault)
|
||||
s.False(created)
|
||||
}
|
||||
|
||||
func (s *CreateOrUpdateTestSuite) TestCreatesOrUpdatesCorrectEnvironment() {
|
||||
templateJobID := tests.DefaultJobID
|
||||
templateJob := &nomadApi.Job{ID: &templateJobID}
|
||||
s.mockRegisterTemplateJob(templateJob, nil)
|
||||
s.mockCreateOrUpdateEnvironment(true, nil)
|
||||
|
||||
_, err := s.manager.CreateOrUpdate(s.environmentID, s.request)
|
||||
s.NoError(err)
|
||||
s.runnerManagerMock.AssertCalled(s.T(), "CreateOrUpdateEnvironment",
|
||||
s.environmentID, s.request.PrewarmingPoolSize, templateJob, true)
|
||||
}
|
||||
|
||||
func (s *CreateOrUpdateTestSuite) TestReturnsErrorIfCreatesOrUpdateEnvironmentReturnsError() {
|
||||
s.mockRegisterTemplateJob(&nomadApi.Job{}, nil)
|
||||
s.mockCreateOrUpdateEnvironment(false, tests.ErrDefault)
|
||||
_, err := s.manager.CreateOrUpdate(runner.EnvironmentID(tests.DefaultEnvironmentIDAsInteger), s.request)
|
||||
s.ErrorIs(err, tests.ErrDefault)
|
||||
}
|
||||
|
||||
func (s *CreateOrUpdateTestSuite) TestReturnsTrueIfCreatesOrUpdateEnvironmentReturnsTrue() {
|
||||
s.mockRegisterTemplateJob(&nomadApi.Job{}, nil)
|
||||
s.mockCreateOrUpdateEnvironment(true, nil)
|
||||
created, err := s.manager.CreateOrUpdate(runner.EnvironmentID(tests.DefaultEnvironmentIDAsInteger), s.request)
|
||||
s.Require().NoError(err)
|
||||
s.True(created)
|
||||
}
|
||||
|
||||
func (s *CreateOrUpdateTestSuite) TestReturnsFalseIfCreatesOrUpdateEnvironmentReturnsFalse() {
|
||||
s.mockRegisterTemplateJob(&nomadApi.Job{}, nil)
|
||||
s.mockCreateOrUpdateEnvironment(false, nil)
|
||||
created, err := s.manager.CreateOrUpdate(runner.EnvironmentID(tests.DefaultEnvironmentIDAsInteger), s.request)
|
||||
s.Require().NoError(err)
|
||||
s.False(created)
|
||||
}
|
||||
|
||||
func TestParseJob(t *testing.T) {
|
||||
exited := false
|
||||
logger, hook := test.NewNullLogger()
|
||||
logger.ExitFunc = func(i int) {
|
||||
exited = true
|
||||
}
|
||||
|
||||
log = logger.WithField("pkg", "nomad")
|
||||
|
||||
t.Run("parses the given default job", func(t *testing.T) {
|
||||
job := parseJob(templateEnvironmentJobHCL)
|
||||
assert.False(t, exited)
|
||||
assert.NotNil(t, job)
|
||||
})
|
||||
|
||||
t.Run("fatals when given wrong job", func(t *testing.T) {
|
||||
job := parseJob("")
|
||||
assert.True(t, exited)
|
||||
assert.Nil(t, job)
|
||||
assert.Equal(t, logrus.FatalLevel, hook.LastEntry().Level)
|
||||
})
|
||||
}
|
79
internal/environment/template-environment-job.hcl
Normal file
79
internal/environment/template-environment-job.hcl
Normal file
@@ -0,0 +1,79 @@
|
||||
// This is the default job configuration that is used when no path to another default configuration is given
|
||||
|
||||
job "template-0" {
|
||||
datacenters = ["dc1"]
|
||||
type = "batch"
|
||||
|
||||
group "default-group" {
|
||||
ephemeral_disk {
|
||||
migrate = false
|
||||
size = 10
|
||||
sticky = false
|
||||
}
|
||||
count = 1
|
||||
scaling {
|
||||
enabled = true
|
||||
min = 0
|
||||
max = 300
|
||||
}
|
||||
spread {
|
||||
// see https://www.nomadproject.io/docs/job-specification/spread#even-spread-across-data-center
|
||||
// This spreads the load evenly amongst our nodes
|
||||
attribute = "${node.unique.name}"
|
||||
weight = 100
|
||||
}
|
||||
|
||||
task "default-task" {
|
||||
driver = "docker"
|
||||
kill_timeout = "0s"
|
||||
kill_signal = "SIGKILL"
|
||||
|
||||
config {
|
||||
image = "drp.codemoon.xopic.de/openhpi/co_execenv_python:3.8"
|
||||
command = "sleep"
|
||||
args = ["infinity"]
|
||||
network_mode = "none"
|
||||
}
|
||||
|
||||
logs {
|
||||
max_files = 1
|
||||
max_file_size = 1
|
||||
}
|
||||
|
||||
resources {
|
||||
cpu = 40
|
||||
memory = 40
|
||||
}
|
||||
|
||||
restart {
|
||||
delay = "0s"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
group "config" {
|
||||
// We want to store whether a task is in use in order to recover from a downtime.
|
||||
// Without a separate config task, marking a task as used would result in a restart of that task,
|
||||
// as the meta information is passed to the container as environment variables.
|
||||
count = 0
|
||||
task "config" {
|
||||
driver = "exec"
|
||||
config {
|
||||
command = "true"
|
||||
}
|
||||
logs {
|
||||
max_files = 1
|
||||
max_file_size = 1
|
||||
}
|
||||
resources {
|
||||
// minimum values
|
||||
cpu = 1
|
||||
memory = 10
|
||||
}
|
||||
}
|
||||
meta {
|
||||
used = "false"
|
||||
prewarmingPoolSize = "0"
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user