func (sb *sandboxBuilder) AttachProxy(hostname string, handler http.Handler) error { // Validate hostname against allowed patterns if !proxyNamePattern.MatchString(hostname) { return engines.NewMalformedPayloadError("Proxy hostname: '", hostname, "'", " is not allowed for QEMU engine. The hostname must match: ", proxyNamePattern.String()) } // Ensure that we're not using the magic "engine" hostname if hostname == "engine" { return engines.NewMalformedPayloadError("Proxy hostname: 'engine' is " + "reserved for internal use (meta-data service)") } // Acquire the lock sb.m.Lock() defer sb.m.Unlock() // Check that the hostname isn't already in use if _, ok := sb.proxies[hostname]; ok { return engines.ErrNamingConflict } // Otherwise set the handler sb.proxies[hostname] = handler return nil }
// LoadMachine will load machine definition from file func LoadMachine(machineFile string) (*Machine, error) { // Load the machine configuration machineData, err := ioext.BoundedReadFile(machineFile, 1024*1024) if err == ioext.ErrFileTooBig { return nil, engines.NewMalformedPayloadError( "The file 'machine.json' larger than 1MiB. JSON files must be small.") } if err != nil { return nil, err } // Parse json m := &Machine{} err = json.Unmarshal(machineData, m) if err != nil { return nil, engines.NewMalformedPayloadError( "Invalid JSON in 'machine.json', error: ", err) } // Validate the definition if err := m.Validate(); err != nil { return nil, err } return m, nil }
// DownloadImage returns a Downloader that will download the image from the // given url. This will attempt multiple retries if necessary. // // If there is a non-200 response this will return a MalformedPayloadError. func DownloadImage(url string) Downloader { // TODO: Add some logging, I really want to abstract away Logger // TODO: This method could probably exist somewhere else, in say runtime // downloading from a URL to a file or stream with retries, etc. is a // common thing. Using range headers for retries and getting integrity // checks right is hard. return func(target string) error { // Create output file out, err := os.Create(target) if err != nil { return err } defer out.Close() attempt := 1 for { // Move to start of file and truncate the file _, err = out.Seek(0, 0) if err != nil { panic("Unable to seek to file start") } err = out.Truncate(0) if err != nil { panic("Unable to truncate file") } // Create a GET request res, err := http.Get(url) if err != nil { goto retry } if 500 <= res.StatusCode && res.StatusCode < 600 { err = fmt.Errorf("Image download failed with status code: %d", res.StatusCode) goto retry } if res.StatusCode != 200 { return engines.NewMalformedPayloadError( "Image download failed with status code: ", res.StatusCode, ) } // Copy response to file // TODO: Make integrity check with x-amx-meta-content-sha256 (if present) // TODO: Use range headers for retry, if supported and checksum for // integrity check is present (otherwise request from start) _, err = io.Copy(out, res.Body) res.Body.Close() if err == nil { return nil } retry: if attempt >= maxRetries { return err } attempt++ got.DefaultBackOff.Delay(attempt) } } }
func (tp *taskPlugin) Stopped(result engines.ResultSet) (bool, error) { nonFatalErrs := []engines.MalformedPayloadError{} for _, artifact := range tp.artifacts { // If expires is set to this time it's the default value if artifact.Expires.IsZero() { artifact.Expires = time.Time(tp.context.TaskInfo.Expires) } switch artifact.Type { case "directory": err := result.ExtractFolder(artifact.Path, tp.createUploadHandler(artifact.Name, artifact.Expires)) if err != nil { if tp.errorHandled(artifact.Name, artifact.Expires, err) { nonFatalErrs = append(nonFatalErrs, engines.NewMalformedPayloadError(err.Error())) continue } return false, err } case "file": fileReader, err := result.ExtractFile(artifact.Path) if err != nil { if tp.errorHandled(artifact.Name, artifact.Expires, err) { nonFatalErrs = append(nonFatalErrs, engines.NewMalformedPayloadError(err.Error())) continue } return false, err } err = tp.attemptUpload(fileReader, artifact.Path, artifact.Name, artifact.Expires) if err != nil { return false, err } } } if len(nonFatalErrs) > 0 { return false, engines.MergeMalformedPayload(nonFatalErrs...) } return true, nil }
func (b *sandboxBuilder) SetEnvironmentVariable(name string, value string) error { if !envVarPattern.MatchString(name) { return engines.NewMalformedPayloadError( "Environment variables name: '", name, "' doesn't match: ", envVarPattern.String(), ) } if _, ok := b.env[name]; ok { return engines.ErrNamingConflict } b.env[name] = value return nil }
func (p taskPlugin) BuildSandbox(sandboxBuilder engines.SandboxBuilder) error { for k, v := range p.variables { err := sandboxBuilder.SetEnvironmentVariable(k, v) // We can only return MalFormedPayloadError switch err { case engines.ErrNamingConflict: return engines.NewMalformedPayloadError("Environment variable ", k, " has already been set.") case engines.ErrFeatureNotSupported: return engines.NewMalformedPayloadError( "Cannot set environment variable ", k, ". Engine does not support this operation") case nil: // break default: return err } } return nil }
func (s *sandbox) SetEnvironmentVariable(name string, value string) error { s.Lock() defer s.Unlock() if strings.Contains(name, " ") { return engines.NewMalformedPayloadError( "MockEngine environment variable names cannot contain space.", "Was given environment variable name: '", name, "' which isn't allowed!", ) } if _, ok := s.env[name]; ok { return engines.ErrNamingConflict } s.env[name] = value return nil }
func (s *sandbox) AttachProxy(name string, handler http.Handler) error { // Lock before we access proxies as this method may be called concurrently s.Lock() defer s.Unlock() if strings.ContainsAny(name, " ") { return engines.NewMalformedPayloadError( "MockEngine proxy names cannot contain space.", "Was given proxy name: '", name, "' which isn't allowed!", ) } if s.proxies[name] != nil { return engines.ErrNamingConflict } s.proxies[name] = handler return nil }
// SetDefaults will validate limitations and set defaults from options func (m *Machine) SetDefaults(options MachineOptions) error { // Set defaults if m.Memory == 0 { m.Memory = options.MaxMemory } // Validate limitations if m.Memory > options.MaxMemory { return engines.NewMalformedPayloadError( "Image memory ", m.Memory, " MiB is larger than allowed machine memory ", options.MaxMemory, " MiB", ) } return nil }
func (s *sandbox) WaitForResult() (engines.ResultSet, error) { // No need to lock access to payload, as it can't be mutated at this point select { case <-s.done: s.result = false return s, errors.New("Task execution has been aborted") case <-time.After(time.Duration(s.payload.Delay) * time.Millisecond): // No need to lock access mounts and proxies either f := functions[s.payload.Function] if f == nil { return nil, engines.NewMalformedPayloadError("Unknown function") } result := f(s, s.payload.Argument) s.Lock() defer s.Unlock() s.result = result return s, nil } }
func (e engine) NewSandboxBuilder(options engines.SandboxOptions) (engines.SandboxBuilder, error) { var taskPayload payloadType err := payloadSchema.Map(options.Payload, &taskPayload) if err == schematypes.ErrTypeMismatch { panic("Type mismatch") } else if err != nil { return nil, engines.NewMalformedPayloadError("Invalid payload: ", err) } var m sync.Mutex return sandboxbuilder{ SandboxBuilderBase: engines.SandboxBuilderBase{}, env: map[string]string{}, taskPayload: &taskPayload, context: options.TaskContext, envMutex: &m, engine: &e, }, nil }
func (r resultset) ExtractFile(path string) (ioext.ReadSeekCloser, error) { cwd := getWorkingDir(r.taskUser, r.context) if !r.validPath(cwd, path) { return nil, engines.NewMalformedPayloadError(path + " is invalid") } if !filepath.IsAbs(path) { path = filepath.Join(cwd, path) } file, err := os.Open(path) if err != nil { r.context.LogError(err) return nil, engines.ErrResourceNotFound } return file, nil }
func (sb *sandboxBuilder) SetEnvironmentVariable(name, value string) error { // Simple sanity check of environment variable names if !envVarPattern.MatchString(name) { return engines.NewMalformedPayloadError("Environment variable name: '", name, "' is not allowed for QEMU engine. Environment variable names", " must be on the form: ", envVarPattern.String()) } // Acquire the lock sb.m.Lock() defer sb.m.Unlock() // Check if the name is already used if _, ok := sb.env[name]; ok { return engines.ErrNamingConflict } // Set the env var sb.env[name] = value return nil }
func (r *resultSet) ExtractFile(path string) (ioext.ReadSeekCloser, error) { // Evaluate symlinks p, err := filepath.EvalSymlinks(filepath.Join(r.homeFolder.Path(), path)) if err != nil { if _, ok := err.(*os.PathError); ok { return nil, engines.ErrResourceNotFound } return nil, engines.NewMalformedPayloadError( "Unable to evaluate path: ", path, ) } // Cleanup the path p = filepath.Clean(p) // Check that p is inside homeFolder if !strings.HasPrefix(p, r.homeFolder.Path()+string(filepath.Separator)) { return nil, engines.ErrResourceNotFound } // Stat the file to make sure it's a file info, err := os.Lstat(p) if err != nil { return nil, engines.ErrResourceNotFound } // Don't allow anything that isn't a plain file if !ioext.IsPlainFileInfo(info) { return nil, engines.ErrResourceNotFound } // Open file f, err := os.Open(p) if err != nil { return nil, engines.ErrResourceNotFound } return f, nil }
func (r resultset) ExtractFolder(path string, handler engines.FileHandler) error { cwd := getWorkingDir(r.taskUser, r.context) if !r.validPath(cwd, path) { return engines.NewMalformedPayloadError(path + " is invalid") } if !filepath.IsAbs(path) { path = filepath.Join(cwd, path) } return filepath.Walk(path, func(p string, info os.FileInfo, e error) error { if e != nil { r.context.LogError(e) return engines.ErrResourceNotFound } if !info.IsDir() { file, err := os.Open(p) if err != nil { r.context.LogError(err) return engines.ErrResourceNotFound } p, err = filepath.Rel(path, p) if err != nil { r.context.LogError(err) return engines.ErrResourceNotFound } err = handler(p, file) if err != nil { return err } } return nil }) }
// Validate returns a MalformedPayloadError if the Machine definition isn't // valid and legal. func (m *Machine) Validate() error { hasError := false errs := "Invalid machine definition in 'machine.json'" msg := func(a ...interface{}) { errs += "\n" + fmt.Sprint(a...) hasError = true } // Render to JSON so we can validate with gojsonschema // (this isn't efficient, but we'll rarely do this so who cares) data, err := json.MarshalIndent(m, "", " ") if err != nil { panic(fmt.Sprintln( "json.Marshal should never fail for vm.Machine, error: ", err, )) } // Validate against JSON schema result, err := machineSchema.Validate( gojsonschema.NewStringLoader(string(data)), ) if err != nil { panic(fmt.Sprintln( "machineSchema.Validate should always be able to validate, error: ", err, )) } if !result.Valid() { for _, err := range result.Errors() { msg(err.(*gojsonschema.ResultErrorFields).String()) } } // Return any errors collected if hasError { return engines.NewMalformedPayloadError(errs) } return nil }
func (s *sandbox) AttachVolume(mountpoint string, v engines.Volume, readOnly bool) error { // We can type cast Volume to our internal type as we know the volume was // created by NewCacheFolder() or NewMemoryDisk(), this is a contract. vol, valid := v.(*volume) if !valid { // TODO: Write to some sort of log if the type assertion fails return engines.ErrContractViolation } // Lock before we access mounts as this method may be called concurrently s.Lock() defer s.Unlock() if strings.ContainsAny(mountpoint, " ") { return engines.NewMalformedPayloadError("MockEngine mountpoints cannot contain space") } if s.mounts[mountpoint] != nil { return engines.ErrNamingConflict } s.mounts[mountpoint] = &mount{ volume: vol, readOnly: readOnly, } return nil }
// prepareStage is where task plugins are prepared and a sandboxbuilder is created. func (t *TaskRun) prepareStage() error { t.log.Debug("Preparing task run") // Parse payload payload := map[string]interface{}{} err := json.Unmarshal([]byte(t.definition.Payload), &payload) if err != nil { err = engines.NewMalformedPayloadError(fmt.Sprintf("Invalid task payload. %s", err)) t.context.LogError(err.Error()) return err } // Construct payload schema payloadSchema, err := schematypes.Merge( t.engine.PayloadSchema(), t.plugin.PayloadSchema(), ) if err != nil { panic(fmt.Sprintf( "Conflicting plugin and engine payload properties, error: %s", err, )) } // Validate payload against schema err = payloadSchema.Validate(payload) if err != nil { err = engines.NewMalformedPayloadError("Schema violation: ", err) t.context.LogError(err.Error()) return err } // Create TaskPlugin t.taskPlugin, err = t.plugin.NewTaskPlugin(plugins.TaskPluginOptions{ TaskInfo: &runtime.TaskInfo{ TaskID: t.TaskID, RunID: t.RunID, }, Payload: t.plugin.PayloadSchema().Filter(payload), Log: t.log.WithField("plugin", "pluginManager"), }) if err != nil { // TODO: We need to review all this... t.context.LogError is for task errors // hence MalformedPayloadError only, not for internal-errors!!! t.context.LogError(err.Error()) return err } // Prepare TaskPlugin err = t.taskPlugin.Prepare(t.context) if err != nil { t.context.LogError(fmt.Sprintf("Could not prepare task plugins. %s", err)) return err } sandboxBuilder, err := t.engine.NewSandboxBuilder(engines.SandboxOptions{ TaskContext: t.context, Payload: t.engine.PayloadSchema().Filter(payload), }) t.m.Lock() t.sandboxBuilder = sandboxBuilder t.m.Unlock() if err != nil { t.context.LogError(fmt.Sprintf("Could not create task execution environment. %s", err)) return err } return nil }
func (r *resultSet) ExtractFolder(path string, handler engines.FileHandler) error { // Evaluate symlinks p, err := filepath.EvalSymlinks(filepath.Join(r.homeFolder.Path(), path)) if err != nil { if _, ok := err.(*os.PathError); ok { return engines.ErrResourceNotFound } return engines.NewMalformedPayloadError( "Unable to evaluate path: ", path, ) } // Cleanup the path p = filepath.Clean(p) // Check that p is inside homeFolder if !strings.HasPrefix(p, r.homeFolder.Path()+string(filepath.Separator)) { return engines.ErrResourceNotFound } first := true return filepath.Walk(p, func(abspath string, info os.FileInfo, err error) error { // If there is a path error, on the first call then the folder is missing if _, ok := err.(*os.PathError); ok && first { return engines.ErrResourceNotFound } first = false // Ignore folder we can't walk (probably a permission issues) if err != nil { return nil } // Skip anything that isn't a plain file if !ioext.IsPlainFileInfo(info) { return nil } // If we can't construct relative file path this internal error, we'll skip relpath, err := filepath.Rel(p, abspath) if err != nil { // TODO: Send error to sentry r.log.Errorf( "ExtractFolder from %s, filepath.Rel('%s', '%s') returns error: %s", path, p, abspath, err, ) return nil } f, err := os.Open(abspath) if err != nil { // file must have been deleted as we tried to open it // that makes no sense, but who knows... return nil } // If handler returns an error we return ErrHandlerInterrupt if handler(filepath.ToSlash(relpath), f) != nil { return engines.ErrHandlerInterrupt } return nil }) }
// extractImage will extract the "disk.img", "layer.qcow2" and "machine.json" // files from a tar archive using GNU tar ensuring that sparse entries will be // extracted as sparse files. // // This also validates that files aren't symlinks and are in correct format, // with legal backing_file parameters. // // Returns a MalformedPayloadError if we believe extraction failed due to a // badly formatted image. func extractImage(imageFile, imageFolder string) (*vm.Machine, error) { // Restrict file to some maximum size if !ioext.IsPlainFile(imageFile) { return nil, fmt.Errorf("extractImage: imageFile is not a file") } if !ioext.IsFileLessThan(imageFile, maxImageSize) { return nil, engines.NewMalformedPayloadError("Image file is larger than ", maxImageSize, " bytes") } // Using zstd | tar so we get sparse files (sh to get OS pipes) tar := exec.Command("sh", "-fec", "zstd -dqc '"+imageFile+"' | "+ "tar -xoC '"+imageFolder+"' --no-same-permissions -- "+ "disk.img layer.qcow2 machine.json", ) _, err := tar.Output() if err != nil { if ee, ok := err.(*exec.ExitError); ok { return nil, engines.NewMalformedPayloadError( "Failed to extract image archieve, error: ", string(ee.Stderr), ) } // If this wasn't GNU tar exiting non-zero then it must be some internal // error. Perhaps tar is missing from the PATH. return nil, fmt.Errorf("Failed to extract image archieve, error: %s", err) } // Check files exist, are plain files and not larger than maxImageSize for _, name := range []string{"disk.img", "layer.qcow2", "machine.json"} { f := filepath.Join(imageFolder, name) if !ioext.IsPlainFile(f) { return nil, engines.NewMalformedPayloadError("Image file is missing '", name, "'") } if !ioext.IsFileLessThan(f, maxImageSize) { return nil, engines.NewMalformedPayloadError("Image file contains '", name, "' larger than ", maxImageSize, " bytes") } } // Load the machine configuration machineFile := filepath.Join(imageFolder, "machine.json") machine, err := vm.LoadMachine(machineFile) if err != nil { return nil, err } // Inspect the raw disk file diskFile := filepath.Join(imageFolder, "disk.img") diskInfo := inspectImageFile(diskFile, imageRawFormat) if diskInfo == nil || diskInfo.Format != formatRaw { return nil, engines.NewMalformedPayloadError("Image file contains ", "'disk.img' which is not a RAW image file") } if diskInfo.VirtualSize > maxImageSize { return nil, engines.NewMalformedPayloadError("Image file contains ", "'disk.img' has virtual size larger than ", maxImageSize, " bytes") } if diskInfo.DirtyFlag { return nil, engines.NewMalformedPayloadError("Image file contains ", "'disk.img' which has the dirty-flag set") } if diskInfo.BackingFile != "" { return nil, engines.NewMalformedPayloadError("Image file contains ", "'disk.img' which has a backing file, this is not permitted") } // Inspect the QCOW2 layer file layerFile := filepath.Join(imageFolder, "layer.qcow2") layerInfo := inspectImageFile(layerFile, imageQCOW2Format) if layerInfo == nil || layerInfo.Format != formatQCOW2 { return nil, engines.NewMalformedPayloadError("Image file contains ", "'layer.qcow2' which is not a QCOW2 file") } if layerInfo.VirtualSize > maxImageSize { return nil, engines.NewMalformedPayloadError("Image file contains ", "'layer.qcow2' has virtual size larger than ", maxImageSize, " bytes") } if layerInfo.DirtyFlag { return nil, engines.NewMalformedPayloadError("Image file contains ", "'layer.qcow2' which has the dirty-flag set") } if layerInfo.BackingFile != "disk.img" { return nil, engines.NewMalformedPayloadError("Image file contains ", "'layer.qcow2' which has a backing file that isn't: 'disk.img'") } if layerInfo.BackingFormat != formatRaw { return nil, engines.NewMalformedPayloadError("Image file contains ", "'layer.qcow2' which has a backing file format that isn't 'raw'") } return machine, nil }