diff options
Diffstat (limited to 'pkg/machine')
-rw-r--r-- | pkg/machine/config.go | 132 | ||||
-rw-r--r-- | pkg/machine/connection.go | 50 | ||||
-rw-r--r-- | pkg/machine/fcos.go | 162 | ||||
-rw-r--r-- | pkg/machine/fcos_amd64.go | 68 | ||||
-rw-r--r-- | pkg/machine/fcos_arm64.go | 169 | ||||
-rw-r--r-- | pkg/machine/ignition.go | 151 | ||||
-rw-r--r-- | pkg/machine/ignition_schema.go | 251 | ||||
-rw-r--r-- | pkg/machine/keys.go | 25 | ||||
-rw-r--r-- | pkg/machine/libvirt/config.go | 4 | ||||
-rw-r--r-- | pkg/machine/libvirt/machine.go | 15 | ||||
-rw-r--r-- | pkg/machine/pull.go | 97 | ||||
-rw-r--r-- | pkg/machine/qemu/config.go | 43 | ||||
-rw-r--r-- | pkg/machine/qemu/machine.go | 304 | ||||
-rw-r--r-- | pkg/machine/qemu/options_amd64.go | 26 | ||||
-rw-r--r-- | pkg/machine/qemu/options_arm64.go | 40 |
15 files changed, 1537 insertions, 0 deletions
diff --git a/pkg/machine/config.go b/pkg/machine/config.go new file mode 100644 index 000000000..5e90dae51 --- /dev/null +++ b/pkg/machine/config.go @@ -0,0 +1,132 @@ +package machine + +import ( + "net" + "net/url" + "os" + "path/filepath" + + "github.com/containers/storage/pkg/homedir" +) + +type CreateOptions struct { + Name string + CPUS uint64 + Memory uint64 + IgnitionPath string + ImagePath string + Username string + URI url.URL + IsDefault bool + //KernelPath string + //Devices []VMDevices +} + +type RemoteConnectionType string + +var ( + SSHRemoteConnection RemoteConnectionType = "ssh" + DefaultIgnitionUserName = "core" +) + +type Download struct { + Arch string + Artifact string + CompressionType string + Format string + ImageName string `json:"image_name"` + LocalPath string + LocalUncompressedFile string + Sha256sum string + URL *url.URL + VMName string +} + +type SSHOptions struct{} +type StartOptions struct{} + +type StopOptions struct{} + +type DestroyOptions struct { + Force bool + SaveKeys bool + SaveImage bool + SaveIgnition bool +} + +type VM interface { + Create(opts CreateOptions) error + Destroy(name string, opts DestroyOptions) (string, func() error, error) + SSH(name string, opts SSHOptions) error + Start(name string, opts StartOptions) error + Stop(name string, opts StopOptions) error +} + +type DistributionDownload interface { + DownloadImage() error + Get() *Download +} + +// TODO is this even needed? +type TestVM struct{} + +func (vm *TestVM) Create(opts CreateOptions) error { + return nil +} + +func (vm *TestVM) Start(name string, opts StartOptions) error { + return nil +} +func (vm *TestVM) Stop(name string, opts StopOptions) error { + return nil +} + +func (rc RemoteConnectionType) MakeSSHURL(host, path, port, userName string) url.URL { + userInfo := url.User(userName) + uri := url.URL{ + Scheme: "ssh", + Opaque: "", + User: userInfo, + Host: host, + Path: path, + RawPath: "", + ForceQuery: false, + RawQuery: "", + Fragment: "", + RawFragment: "", + } + if len(port) > 0 { + uri.Host = net.JoinHostPort(uri.Hostname(), port) + } + return uri +} + +// GetDataDir returns the filepath where vm images should +// live for podman-machine +func GetDataDir(vmType string) (string, error) { + data, err := homedir.GetDataHome() + if err != nil { + return "", err + } + dataDir := filepath.Join(data, "containers", "podman", "machine", vmType) + if _, err := os.Stat(dataDir); !os.IsNotExist(err) { + return dataDir, nil + } + mkdirErr := os.MkdirAll(dataDir, 0755) + return dataDir, mkdirErr +} + +// GetConfigDir returns the filepath to where configuration +// files for podman-machine should live +func GetConfDir(vmType string) (string, error) { + conf, err := homedir.GetConfigHome() + if err != nil { + return "", err + } + confDir := filepath.Join(conf, "containers", "podman", "machine", vmType) + if _, err := os.Stat(confDir); !os.IsNotExist(err) { + return confDir, nil + } + mkdirErr := os.MkdirAll(confDir, 0755) + return confDir, mkdirErr +} diff --git a/pkg/machine/connection.go b/pkg/machine/connection.go new file mode 100644 index 000000000..e3985d8ac --- /dev/null +++ b/pkg/machine/connection.go @@ -0,0 +1,50 @@ +package machine + +import ( + "fmt" + + "github.com/containers/common/pkg/config" + "github.com/pkg/errors" +) + +func AddConnection(uri fmt.Stringer, name, identity string, isDefault bool) error { + if len(identity) < 1 { + return errors.New("identity must be defined") + } + cfg, err := config.ReadCustomConfig() + if err != nil { + return err + } + if _, ok := cfg.Engine.ServiceDestinations[name]; ok { + return errors.New("cannot overwrite connection") + } + if isDefault { + cfg.Engine.ActiveService = name + } + dst := config.Destination{ + URI: uri.String(), + } + dst.Identity = identity + if cfg.Engine.ServiceDestinations == nil { + cfg.Engine.ServiceDestinations = map[string]config.Destination{ + name: dst, + } + cfg.Engine.ActiveService = name + } else { + cfg.Engine.ServiceDestinations[name] = dst + } + return cfg.Write() +} + +func RemoveConnection(name string) error { + cfg, err := config.ReadCustomConfig() + if err != nil { + return err + } + if _, ok := cfg.Engine.ServiceDestinations[name]; ok { + delete(cfg.Engine.ServiceDestinations, name) + } else { + return errors.Errorf("unable to find connection named %q", name) + } + return cfg.Write() +} diff --git a/pkg/machine/fcos.go b/pkg/machine/fcos.go new file mode 100644 index 000000000..8bbad458f --- /dev/null +++ b/pkg/machine/fcos.go @@ -0,0 +1,162 @@ +package machine + +import ( + "crypto/sha256" + "io" + "io/ioutil" + url2 "net/url" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/containers/storage/pkg/archive" + + "github.com/sirupsen/logrus" + + digest "github.com/opencontainers/go-digest" +) + +// These should eventually be moved into machine/qemu as +// they are specific to running qemu +var ( + artifact string = "qemu" + Format string = "qcow2.xz" +) + +type FcosDownload struct { + Download +} + +func NewFcosDownloader(vmType, vmName string) (DistributionDownload, error) { + info, err := getFCOSDownload() + if err != nil { + return nil, err + } + urlSplit := strings.Split(info.Location, "/") + imageName := urlSplit[len(urlSplit)-1] + url, err := url2.Parse(info.Location) + if err != nil { + return nil, err + } + + dataDir, err := GetDataDir(vmType) + if err != nil { + return nil, err + } + + fcd := FcosDownload{ + Download: Download{ + Arch: getFcosArch(), + Artifact: artifact, + Format: Format, + ImageName: imageName, + LocalPath: filepath.Join(dataDir, imageName), + Sha256sum: info.Sha256Sum, + URL: url, + VMName: vmName, + }, + } + fcd.Download.LocalUncompressedFile = fcd.getLocalUncompressedName() + return fcd, nil +} + +func (f FcosDownload) getLocalUncompressedName() string { + uncompressedFilename := filepath.Join(filepath.Dir(f.LocalPath), f.VMName+"_"+f.ImageName) + return strings.TrimSuffix(uncompressedFilename, ".xz") +} + +func (f FcosDownload) DownloadImage() error { + // check if the latest image is already present + ok, err := UpdateAvailable(&f.Download) + if err != nil { + return err + } + if !ok { + if err := DownloadVMImage(f.URL, f.LocalPath); err != nil { + return err + } + } + uncompressedFileWriter, err := os.OpenFile(f.getLocalUncompressedName(), os.O_CREATE|os.O_RDWR, 0600) + if err != nil { + return err + } + sourceFile, err := ioutil.ReadFile(f.LocalPath) + if err != nil { + return err + } + compressionType := archive.DetectCompression(sourceFile) + f.CompressionType = compressionType.Extension() + + switch f.CompressionType { + case "tar.xz": + return decompressXZ(f.LocalPath, uncompressedFileWriter) + default: + // File seems to be uncompressed, make a copy + if err := copyFile(f.LocalPath, uncompressedFileWriter); err != nil { + return err + } + } + return nil +} + +func copyFile(src string, dest *os.File) error { + source, err := os.Open(src) + if err != nil { + return err + } + defer func() { + if err := source.Close(); err != nil { + logrus.Error(err) + } + }() + _, err = io.Copy(dest, source) + return err +} + +func (f FcosDownload) Get() *Download { + return &f.Download +} + +type fcosDownloadInfo struct { + CompressionType string + Location string + Release string + Sha256Sum string +} + +func UpdateAvailable(d *Download) (bool, error) { + // check the sha of the local image if it exists + // get the sha of the remote image + // == dont bother to pull + files, err := ioutil.ReadDir(filepath.Dir(d.LocalPath)) + if err != nil { + return false, err + } + for _, file := range files { + if filepath.Base(d.LocalPath) == file.Name() { + b, err := ioutil.ReadFile(d.LocalPath) + if err != nil { + return false, err + } + s := sha256.Sum256(b) + sum := digest.NewDigestFromBytes(digest.SHA256, s[:]) + if sum.Encoded() == d.Sha256sum { + return true, nil + } + } + } + return false, nil +} + +func getFcosArch() string { + var arch string + // TODO fill in more architectures + switch runtime.GOARCH { + case "arm64": + arch = "aarch64" + default: + arch = "x86_64" + } + return arch +} diff --git a/pkg/machine/fcos_amd64.go b/pkg/machine/fcos_amd64.go new file mode 100644 index 000000000..36676405a --- /dev/null +++ b/pkg/machine/fcos_amd64.go @@ -0,0 +1,68 @@ +package machine + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + + "github.com/coreos/stream-metadata-go/fedoracoreos" + "github.com/coreos/stream-metadata-go/stream" + "github.com/sirupsen/logrus" +) + +// This should get Exported and stay put as it will apply to all fcos downloads +// getFCOS parses fedoraCoreOS's stream and returns the image download URL and the release version +func getFCOSDownload() (*fcosDownloadInfo, error) { + var ( + fcosstable stream.Stream + ) + streamurl := fedoracoreos.GetStreamURL(fedoracoreos.StreamNext) + resp, err := http.Get(streamurl.String()) + if err != nil { + return nil, err + } + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + defer func() { + if err := resp.Body.Close(); err != nil { + logrus.Error(err) + } + }() + + if err := json.Unmarshal(body, &fcosstable); err != nil { + return nil, err + } + arch, ok := fcosstable.Architectures[getFcosArch()] + if !ok { + return nil, fmt.Errorf("unable to pull VM image: no targetArch in stream") + } + artifacts := arch.Artifacts + if artifacts == nil { + return nil, fmt.Errorf("unable to pull VM image: no artifact in stream") + } + qemu, ok := artifacts[artifact] + if !ok { + return nil, fmt.Errorf("unable to pull VM image: no qemu artifact in stream") + } + formats := qemu.Formats + if formats == nil { + return nil, fmt.Errorf("unable to pull VM image: no formats in stream") + } + qcow, ok := formats[Format] + if !ok { + return nil, fmt.Errorf("unable to pull VM image: no qcow2.xz format in stream") + } + disk := qcow.Disk + if disk == nil { + return nil, fmt.Errorf("unable to pull VM image: no disk in stream") + } + return &fcosDownloadInfo{ + Location: disk.Location, + Release: qemu.Release, + Sha256Sum: disk.Sha256, + CompressionType: "xz", + }, nil +} diff --git a/pkg/machine/fcos_arm64.go b/pkg/machine/fcos_arm64.go new file mode 100644 index 000000000..ab50ca874 --- /dev/null +++ b/pkg/machine/fcos_arm64.go @@ -0,0 +1,169 @@ +package machine + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + + "github.com/sirupsen/logrus" +) + +const aarchBaseURL = "https://fedorapeople.org/groups/fcos-images/builds/latest/aarch64/" + +// Total hack until automation is possible. +// We need a proper json file at least to automate +func getFCOSDownload() (*fcosDownloadInfo, error) { + + meta := Build{} + fmt.Println(aarchBaseURL + "meta.json") + resp, err := http.Get(aarchBaseURL + "meta.json") + if err != nil { + return nil, err + } + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + defer func() { + if err := resp.Body.Close(); err != nil { + logrus.Error(err) + } + }() + if err := json.Unmarshal(body, &meta); err != nil { + return nil, err + } + return &fcosDownloadInfo{ + Location: "https://fedorapeople.org/groups/fcos-images/builds/latest/aarch64/fedora-coreos-33.20210310.dev.0-qemu.aarch64.qcow2", + Release: "", + Sha256Sum: meta.BuildArtifacts.Qemu.Sha256, + }, nil +} + +/* + All of this can be nuked when fcos upstream generates a proper meta data file for aarch. +*/ +type AliyunImage struct { + ImageID string `json:"id"` + Region string `json:"name"` +} + +type Amis struct { + Hvm string `json:"hvm"` + Region string `json:"name"` + Snapshot string `json:"snapshot"` +} + +type Artifact struct { + Path string `json:"path"` + Sha256 string `json:"sha256"` + SizeInBytes float64 `json:"size,omitempty"` + UncompressedSha256 string `json:"uncompressed-sha256,omitempty"` + UncompressedSize int `json:"uncompressed-size,omitempty"` +} + +type Build struct { + AlibabaAliyunUploads []AliyunImage `json:"aliyun,omitempty"` + Amis []Amis `json:"amis,omitempty"` + Architecture string `json:"coreos-assembler.basearch,omitempty"` + Azure *Cloudartifact `json:"azure,omitempty"` + BuildArtifacts *BuildArtifacts `json:"images,omitempty"` + BuildID string `json:"buildid"` + BuildRef string `json:"ref,omitempty"` + BuildSummary string `json:"summary"` + BuildTimeStamp string `json:"coreos-assembler.build-timestamp,omitempty"` + BuildURL string `json:"build-url,omitempty"` + ConfigGitRev string `json:"coreos-assembler.config-gitrev,omitempty"` + ContainerConfigGit *Git `json:"coreos-assembler.container-config-git,omitempty"` + CoreOsSource string `json:"coreos-assembler.code-source,omitempty"` + CosaContainerImageGit *Git `json:"coreos-assembler.container-image-git,omitempty"` + CosaDelayedMetaMerge bool `json:"coreos-assembler.delayed-meta-merge,omitempty"` + CosaImageChecksum string `json:"coreos-assembler.image-config-checksum,omitempty"` + CosaImageVersion int `json:"coreos-assembler.image-genver,omitempty"` + Extensions *Extensions `json:"extensions,omitempty"` + FedoraCoreOsParentCommit string `json:"fedora-coreos.parent-commit,omitempty"` + FedoraCoreOsParentVersion string `json:"fedora-coreos.parent-version,omitempty"` + Gcp *Gcp `json:"gcp,omitempty"` + GitDirty string `json:"coreos-assembler.config-dirty,omitempty"` + ImageInputChecksum string `json:"coreos-assembler.image-input-checksum,omitempty"` + InputHasOfTheRpmOstree string `json:"rpm-ostree-inputhash"` + MetaStamp float64 `json:"coreos-assembler.meta-stamp,omitempty"` + Name string `json:"name"` + Oscontainer *Image `json:"oscontainer,omitempty"` + OstreeCommit string `json:"ostree-commit"` + OstreeContentBytesWritten int `json:"ostree-content-bytes-written,omitempty"` + OstreeContentChecksum string `json:"ostree-content-checksum"` + OstreeNCacheHits int `json:"ostree-n-cache-hits,omitempty"` + OstreeNContentTotal int `json:"ostree-n-content-total,omitempty"` + OstreeNContentWritten int `json:"ostree-n-content-written,omitempty"` + OstreeNMetadataTotal int `json:"ostree-n-metadata-total,omitempty"` + OstreeNMetadataWritten int `json:"ostree-n-metadata-written,omitempty"` + OstreeTimestamp string `json:"ostree-timestamp"` + OstreeVersion string `json:"ostree-version"` + OverridesActive bool `json:"coreos-assembler.overrides-active,omitempty"` + PkgdiffAgainstParent PackageSetDifferences `json:"parent-pkgdiff,omitempty"` + PkgdiffBetweenBuilds PackageSetDifferences `json:"pkgdiff,omitempty"` + ReleasePayload *Image `json:"release-payload,omitempty"` +} + +type BuildArtifacts struct { + Aliyun *Artifact `json:"aliyun,omitempty"` + Aws *Artifact `json:"aws,omitempty"` + Azure *Artifact `json:"azure,omitempty"` + AzureStack *Artifact `json:"azurestack,omitempty"` + Dasd *Artifact `json:"dasd,omitempty"` + DigitalOcean *Artifact `json:"digitalocean,omitempty"` + Exoscale *Artifact `json:"exoscale,omitempty"` + Gcp *Artifact `json:"gcp,omitempty"` + IbmCloud *Artifact `json:"ibmcloud,omitempty"` + Initramfs *Artifact `json:"initramfs,omitempty"` + Iso *Artifact `json:"iso,omitempty"` + Kernel *Artifact `json:"kernel,omitempty"` + LiveInitramfs *Artifact `json:"live-initramfs,omitempty"` + LiveIso *Artifact `json:"live-iso,omitempty"` + LiveKernel *Artifact `json:"live-kernel,omitempty"` + LiveRootfs *Artifact `json:"live-rootfs,omitempty"` + Metal *Artifact `json:"metal,omitempty"` + Metal4KNative *Artifact `json:"metal4k,omitempty"` + OpenStack *Artifact `json:"openstack,omitempty"` + Ostree Artifact `json:"ostree"` + Qemu *Artifact `json:"qemu,omitempty"` + Vmware *Artifact `json:"vmware,omitempty"` + Vultr *Artifact `json:"vultr,omitempty"` +} + +type Cloudartifact struct { + Image string `json:"image"` + URL string `json:"url"` +} + +type Extensions struct { + Manifest map[string]interface{} `json:"manifest"` + Path string `json:"path"` + RpmOstreeState string `json:"rpm-ostree-state"` + Sha256 string `json:"sha256"` +} + +type Gcp struct { + ImageFamily string `json:"family,omitempty"` + ImageName string `json:"image"` + ImageProject string `json:"project,omitempty"` + URL string `json:"url"` +} + +type Git struct { + Branch string `json:"branch,omitempty"` + Commit string `json:"commit"` + Dirty string `json:"dirty,omitempty"` + Origin string `json:"origin"` +} + +type Image struct { + Comment string `json:"comment,omitempty"` + Digest string `json:"digest"` + Image string `json:"image"` +} + +type Items interface{} + +type PackageSetDifferences []Items diff --git a/pkg/machine/ignition.go b/pkg/machine/ignition.go new file mode 100644 index 000000000..ff79d5afb --- /dev/null +++ b/pkg/machine/ignition.go @@ -0,0 +1,151 @@ +package machine + +import ( + "encoding/json" + "io/ioutil" +) + +/* + If this file gets too nuts, we can perhaps use existing go code + to create ignition files. At this point, the file is so simple + that I chose to use structs and not import any code as I was + concerned (unsubstantiated) about too much bloat coming in. + + https://github.com/openshift/machine-config-operator/blob/master/pkg/server/server.go +*/ + +// Convenience function to convert int to ptr +func intToPtr(i int) *int { + return &i +} + +// Convenience function to convert string to ptr +func strToPtr(s string) *string { + return &s +} + +// Convenience function to convert bool to ptr +func boolToPtr(b bool) *bool { + return &b +} + +func getNodeUsr(usrName string) NodeUser { + return NodeUser{Name: &usrName} +} + +func getNodeGrp(grpName string) NodeGroup { + return NodeGroup{Name: &grpName} +} + +// NewIgnitionFile +func NewIgnitionFile(name, key, writePath string) error { + if len(name) < 1 { + name = DefaultIgnitionUserName + } + ignVersion := Ignition{ + Version: "3.2.0", + } + + ignPassword := Passwd{ + Users: []PasswdUser{{ + Name: name, + SSHAuthorizedKeys: []SSHAuthorizedKey{SSHAuthorizedKey(key)}, + }}, + } + + ignStorage := Storage{ + Directories: getDirs(name), + Files: getFiles(name), + Links: getLinks(name), + } + ignSystemd := Systemd{ + Units: []Unit{ + { + Enabled: boolToPtr(true), + Name: "podman.socket", + }}} + + ignConfig := Config{ + Ignition: ignVersion, + Passwd: ignPassword, + Storage: ignStorage, + Systemd: ignSystemd, + } + b, err := json.Marshal(ignConfig) + if err != nil { + return err + } + return ioutil.WriteFile(writePath, b, 0644) +} + +func getDirs(usrName string) []Directory { + // Ignition has a bug/feature? where if you make a series of dirs + // in one swoop, then the leading dirs are creates as root. + newDirs := []string{ + "/home/" + usrName + "/.config", + "/home/" + usrName + "/.config/systemd", + "/home/" + usrName + "/.config/systemd/user", + "/home/" + usrName + "/.config/systemd/user/default.target.wants", + } + var ( + dirs = make([]Directory, len(newDirs)) + ) + for i, d := range newDirs { + newDir := Directory{ + Node: Node{ + Group: getNodeGrp(usrName), + Path: d, + User: getNodeUsr(usrName), + }, + DirectoryEmbedded1: DirectoryEmbedded1{Mode: intToPtr(493)}, + } + dirs[i] = newDir + } + return dirs +} + +func getFiles(usrName string) []File { + var ( + files []File + ) + // Add a fake systemd service to get the user socket rolling + files = append(files, File{ + Node: Node{ + Group: getNodeGrp(usrName), + Path: "/home/" + usrName + "/.config/systemd/user/linger-example.service", + User: getNodeUsr(usrName), + }, + FileEmbedded1: FileEmbedded1{ + Append: nil, + Contents: Resource{ + Source: strToPtr("data:,%5BUnit%5D%0ADescription%3DA%20systemd%20user%20unit%20demo%0AAfter%3Dnetwork-online.target%0AWants%3Dnetwork-online.target%20podman.socket%0A%5BService%5D%0AExecStart%3D%2Fusr%2Fbin%2Fsleep%20infinity%0A"), + }, + Mode: intToPtr(484), + }, + }) + + // Add a file into linger + files = append(files, File{ + Node: Node{ + Group: getNodeGrp(usrName), + Path: "/var/lib/systemd/linger/core", + User: getNodeUsr(usrName), + }, + FileEmbedded1: FileEmbedded1{Mode: intToPtr(420)}, + }) + return files +} + +func getLinks(usrName string) []Link { + return []Link{{ + Node: Node{ + Group: getNodeGrp(usrName), + Path: "/home/" + usrName + "/.config/systemd/user/default.target.wants/linger-example.service", + User: getNodeUsr(usrName), + }, + LinkEmbedded1: LinkEmbedded1{ + Hard: boolToPtr(false), + Target: "/home/" + usrName + "/.config/systemd/user/linger-example.service", + }, + }} +} diff --git a/pkg/machine/ignition_schema.go b/pkg/machine/ignition_schema.go new file mode 100644 index 000000000..9dbd90ba4 --- /dev/null +++ b/pkg/machine/ignition_schema.go @@ -0,0 +1,251 @@ +package machine + +/* + This file was taken from https://github.com/coreos/ignition/blob/master/config/v3_2/types/schema.go in an effort to + use more of the core-os structs but not fully commit to bringing their api in. + + // generated by "schematyper --package=types config/v3_2/schema/ignition.json -o config/v3_2/types/ignition_schema.go --root-type=Config" -- DO NOT EDIT +*/ + +type Clevis struct { + Custom *Custom `json:"custom,omitempty"` + Tang []Tang `json:"tang,omitempty"` + Threshold *int `json:"threshold,omitempty"` + Tpm2 *bool `json:"tpm2,omitempty"` +} + +type Config struct { + Ignition Ignition `json:"ignition"` + Passwd Passwd `json:"passwd,omitempty"` + Storage Storage `json:"storage,omitempty"` + Systemd Systemd `json:"systemd,omitempty"` +} + +type Custom struct { + Config string `json:"config"` + NeedsNetwork *bool `json:"needsNetwork,omitempty"` + Pin string `json:"pin"` +} + +type Device string + +type Directory struct { + Node + DirectoryEmbedded1 +} + +type DirectoryEmbedded1 struct { + Mode *int `json:"mode,omitempty"` +} + +type Disk struct { + Device string `json:"device"` + Partitions []Partition `json:"partitions,omitempty"` + WipeTable *bool `json:"wipeTable,omitempty"` +} + +type Dropin struct { + Contents *string `json:"contents,omitempty"` + Name string `json:"name"` +} + +type File struct { + Node + FileEmbedded1 +} + +type FileEmbedded1 struct { + Append []Resource `json:"append,omitempty"` + Contents Resource `json:"contents,omitempty"` + Mode *int `json:"mode,omitempty"` +} + +type Filesystem struct { + Device string `json:"device"` + Format *string `json:"format,omitempty"` + Label *string `json:"label,omitempty"` + MountOptions []MountOption `json:"mountOptions,omitempty"` + Options []FilesystemOption `json:"options,omitempty"` + Path *string `json:"path,omitempty"` + UUID *string `json:"uuid,omitempty"` + WipeFilesystem *bool `json:"wipeFilesystem,omitempty"` +} + +type FilesystemOption string + +type Group string + +type HTTPHeader struct { + Name string `json:"name"` + Value *string `json:"value,omitempty"` +} + +type HTTPHeaders []HTTPHeader + +type Ignition struct { + Config IgnitionConfig `json:"config,omitempty"` + Proxy Proxy `json:"proxy,omitempty"` + Security Security `json:"security,omitempty"` + Timeouts Timeouts `json:"timeouts,omitempty"` + Version string `json:"version,omitempty"` +} + +type IgnitionConfig struct { + Merge []Resource `json:"merge,omitempty"` + Replace Resource `json:"replace,omitempty"` +} + +type Link struct { + Node + LinkEmbedded1 +} + +type LinkEmbedded1 struct { + Hard *bool `json:"hard,omitempty"` + Target string `json:"target"` +} + +type Luks struct { + Clevis *Clevis `json:"clevis,omitempty"` + Device *string `json:"device,omitempty"` + KeyFile Resource `json:"keyFile,omitempty"` + Label *string `json:"label,omitempty"` + Name string `json:"name"` + Options []LuksOption `json:"options,omitempty"` + UUID *string `json:"uuid,omitempty"` + WipeVolume *bool `json:"wipeVolume,omitempty"` +} + +type LuksOption string + +type MountOption string + +type NoProxyItem string + +type Node struct { + Group NodeGroup `json:"group,omitempty"` + Overwrite *bool `json:"overwrite,omitempty"` + Path string `json:"path"` + User NodeUser `json:"user,omitempty"` +} + +type NodeGroup struct { + ID *int `json:"id,omitempty"` + Name *string `json:"name,omitempty"` +} + +type NodeUser struct { + ID *int `json:"id,omitempty"` + Name *string `json:"name,omitempty"` +} + +type Partition struct { + GUID *string `json:"guid,omitempty"` + Label *string `json:"label,omitempty"` + Number int `json:"number,omitempty"` + Resize *bool `json:"resize,omitempty"` + ShouldExist *bool `json:"shouldExist,omitempty"` + SizeMiB *int `json:"sizeMiB,omitempty"` + StartMiB *int `json:"startMiB,omitempty"` + TypeGUID *string `json:"typeGuid,omitempty"` + WipePartitionEntry *bool `json:"wipePartitionEntry,omitempty"` +} + +type Passwd struct { + Groups []PasswdGroup `json:"groups,omitempty"` + Users []PasswdUser `json:"users,omitempty"` +} + +type PasswdGroup struct { + Gid *int `json:"gid,omitempty"` + Name string `json:"name"` + PasswordHash *string `json:"passwordHash,omitempty"` + ShouldExist *bool `json:"shouldExist,omitempty"` + System *bool `json:"system,omitempty"` +} + +type PasswdUser struct { + Gecos *string `json:"gecos,omitempty"` + Groups []Group `json:"groups,omitempty"` + HomeDir *string `json:"homeDir,omitempty"` + Name string `json:"name"` + NoCreateHome *bool `json:"noCreateHome,omitempty"` + NoLogInit *bool `json:"noLogInit,omitempty"` + NoUserGroup *bool `json:"noUserGroup,omitempty"` + PasswordHash *string `json:"passwordHash,omitempty"` + PrimaryGroup *string `json:"primaryGroup,omitempty"` + SSHAuthorizedKeys []SSHAuthorizedKey `json:"sshAuthorizedKeys,omitempty"` + Shell *string `json:"shell,omitempty"` + ShouldExist *bool `json:"shouldExist,omitempty"` + System *bool `json:"system,omitempty"` + UID *int `json:"uid,omitempty"` +} + +type Proxy struct { + HTTPProxy *string `json:"httpProxy,omitempty"` + HTTPSProxy *string `json:"httpsProxy,omitempty"` + NoProxy []NoProxyItem `json:"noProxy,omitempty"` +} + +type Raid struct { + Devices []Device `json:"devices"` + Level string `json:"level"` + Name string `json:"name"` + Options []RaidOption `json:"options,omitempty"` + Spares *int `json:"spares,omitempty"` +} + +type RaidOption string + +type Resource struct { + Compression *string `json:"compression,omitempty"` + HTTPHeaders HTTPHeaders `json:"httpHeaders,omitempty"` + Source *string `json:"source,omitempty"` + Verification Verification `json:"verification,omitempty"` +} + +type SSHAuthorizedKey string + +type Security struct { + TLS TLS `json:"tls,omitempty"` +} + +type Storage struct { + Directories []Directory `json:"directories,omitempty"` + Disks []Disk `json:"disks,omitempty"` + Files []File `json:"files,omitempty"` + Filesystems []Filesystem `json:"filesystems,omitempty"` + Links []Link `json:"links,omitempty"` + Luks []Luks `json:"luks,omitempty"` + Raid []Raid `json:"raid,omitempty"` +} + +type Systemd struct { + Units []Unit `json:"units,omitempty"` +} + +type TLS struct { + CertificateAuthorities []Resource `json:"certificateAuthorities,omitempty"` +} + +type Tang struct { + Thumbprint *string `json:"thumbprint,omitempty"` + URL string `json:"url,omitempty"` +} + +type Timeouts struct { + HTTPResponseHeaders *int `json:"httpResponseHeaders,omitempty"` + HTTPTotal *int `json:"httpTotal,omitempty"` +} + +type Unit struct { + Contents *string `json:"contents,omitempty"` + Dropins []Dropin `json:"dropins,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + Mask *bool `json:"mask,omitempty"` + Name string `json:"name"` +} + +type Verification struct { + Hash *string `json:"hash,omitempty"` +} diff --git a/pkg/machine/keys.go b/pkg/machine/keys.go new file mode 100644 index 000000000..907e28f55 --- /dev/null +++ b/pkg/machine/keys.go @@ -0,0 +1,25 @@ +package machine + +import ( + "io/ioutil" + "os/exec" + "strings" +) + +// CreateSSHKeys makes a priv and pub ssh key for interacting +// the a VM. +func CreateSSHKeys(writeLocation string) (string, error) { + if err := generatekeys(writeLocation); err != nil { + return "", err + } + b, err := ioutil.ReadFile(writeLocation + ".pub") + if err != nil { + return "", err + } + return strings.TrimSuffix(string(b), "\n"), nil +} + +// generatekeys creates an ed25519 set of keys +func generatekeys(writeLocation string) error { + return exec.Command("ssh-keygen", "-N", "", "-t", "ed25519", "-f", writeLocation).Run() +} diff --git a/pkg/machine/libvirt/config.go b/pkg/machine/libvirt/config.go new file mode 100644 index 000000000..903f15fbc --- /dev/null +++ b/pkg/machine/libvirt/config.go @@ -0,0 +1,4 @@ +package libvirt + +type MachineVM struct { +} diff --git a/pkg/machine/libvirt/machine.go b/pkg/machine/libvirt/machine.go new file mode 100644 index 000000000..2c907ba5f --- /dev/null +++ b/pkg/machine/libvirt/machine.go @@ -0,0 +1,15 @@ +package libvirt + +import "github.com/containers/podman/v3/pkg/machine" + +func (v *MachineVM) Create(name string, opts machine.CreateOptions) error { + return nil +} + +func (v *MachineVM) Start(name string) error { + return nil +} + +func (v *MachineVM) Stop(name string) error { + return nil +} diff --git a/pkg/machine/pull.go b/pkg/machine/pull.go new file mode 100644 index 000000000..39dde15b8 --- /dev/null +++ b/pkg/machine/pull.go @@ -0,0 +1,97 @@ +package machine + +import ( + "fmt" + "io" + "net/http" + "os" + "os/exec" + "strings" + "time" + + "github.com/sirupsen/logrus" + "github.com/vbauerster/mpb/v6" + "github.com/vbauerster/mpb/v6/decor" +) + +// DownloadVMImage downloads a VM image from url to given path +// with download status +func DownloadVMImage(downloadURL fmt.Stringer, localImagePath string) error { + out, err := os.Create(localImagePath) + if err != nil { + return err + } + defer func() { + if err := out.Close(); err != nil { + logrus.Error(err) + } + }() + + resp, err := http.Get(downloadURL.String()) + if err != nil { + return err + } + defer func() { + if err := resp.Body.Close(); err != nil { + logrus.Error(err) + } + }() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("error downloading VM image: %s", resp.Status) + } + size := resp.ContentLength + urlSplit := strings.Split(downloadURL.String(), "/") + prefix := "Downloading VM image: " + urlSplit[len(urlSplit)-1] + onComplete := prefix + ": done" + + p := mpb.New( + mpb.WithWidth(60), + mpb.WithRefreshRate(180*time.Millisecond), + ) + + bar := p.AddBar(size, + mpb.BarFillerClearOnComplete(), + mpb.PrependDecorators( + decor.OnComplete(decor.Name(prefix), onComplete), + ), + mpb.AppendDecorators( + decor.OnComplete(decor.CountersKibiByte("%.1f / %.1f"), ""), + ), + ) + + proxyReader := bar.ProxyReader(resp.Body) + defer func() { + if err := proxyReader.Close(); err != nil { + logrus.Error(err) + } + }() + + if _, err := io.Copy(out, proxyReader); err != nil { + return err + } + + p.Wait() + return nil +} + +// Will error out if file without .xz already exists +// Maybe extracting then renameing is a good idea here.. +// depends on xz: not pre-installed on mac, so it becomes a brew dependecy +func decompressXZ(src string, output io.Writer) error { + fmt.Println("Extracting compressed file") + cmd := exec.Command("xzcat", "-k", src) + //cmd := exec.Command("xz", "-d", "-k", "-v", src) + stdOut, err := cmd.StdoutPipe() + if err != nil { + return err + } + //cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + go func() { + if _, err := io.Copy(output, stdOut); err != nil { + logrus.Error(err) + } + }() + return cmd.Run() +} diff --git a/pkg/machine/qemu/config.go b/pkg/machine/qemu/config.go new file mode 100644 index 000000000..e4687914d --- /dev/null +++ b/pkg/machine/qemu/config.go @@ -0,0 +1,43 @@ +package qemu + +import "time" + +type MachineVM struct { + // CPUs to be assigned to the VM + CPUs uint64 + // The command line representation of the qemu command + CmdLine []string + // IdentityPath is the fq path to the ssh priv key + IdentityPath string + // IgnitionFilePath is the fq path to the .ign file + IgnitionFilePath string + // ImagePath is the fq path to + ImagePath string + // Memory in megabytes assigned to the vm + Memory uint64 + // Name of the vm + Name string + // SSH port for user networking + Port int + // QMPMonitor is the qemu monitor object for sending commands + QMPMonitor Monitor + // RemoteUsername of the vm user + RemoteUsername string +} + +type Monitor struct { + // Address portion of the qmp monitor (/tmp/tmp.sock) + Address string + // Network portion of the qmp monitor (unix) + Network string + // Timeout in seconds for qmp monitor transactions + Timeout time.Duration +} + +var ( + // defaultQMPTimeout is the timeout duration for the + // qmp monitor interactions + defaultQMPTimeout time.Duration = 2 * time.Second + // defaultRemoteUser describes the ssh username default + defaultRemoteUser = "core" +) diff --git a/pkg/machine/qemu/machine.go b/pkg/machine/qemu/machine.go new file mode 100644 index 000000000..92a16dda7 --- /dev/null +++ b/pkg/machine/qemu/machine.go @@ -0,0 +1,304 @@ +package qemu + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "os" + "os/exec" + "path/filepath" + "strconv" + "time" + + "github.com/containers/podman/v3/pkg/machine" + "github.com/containers/podman/v3/pkg/specgen" + "github.com/containers/storage/pkg/homedir" + "github.com/digitalocean/go-qemu/qmp" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" +) + +var ( + // vmtype refers to qemu (vs libvirt, krun, etc) + vmtype = "qemu" + // qemuCommon are the common command line arguments between the arches + //qemuCommon = []string{"-cpu", "host", "-qmp", "unix://tmp/qmp.sock,server,nowait"} + //qemuCommon = []string{"-cpu", "host", "-qmp", "tcp:localhost:4444,server,nowait"} +) + +// NewMachine creates an instance of a virtual machine based on the qemu +// virtualization. +func NewMachine(opts machine.CreateOptions) (machine.VM, error) { + vmConfigDir, err := machine.GetConfDir(vmtype) + if err != nil { + return nil, err + } + vm := new(MachineVM) + if len(opts.Name) > 0 { + vm.Name = opts.Name + } + vm.IgnitionFilePath = opts.IgnitionPath + // If no ignitionfilepath was provided, use defaults + if len(vm.IgnitionFilePath) < 1 { + ignitionFile := filepath.Join(vmConfigDir, vm.Name+".ign") + vm.IgnitionFilePath = ignitionFile + } + + // An image was specified + if len(opts.ImagePath) > 0 { + vm.ImagePath = opts.ImagePath + } + + // Assign remote user name. if not provided, use default + vm.RemoteUsername = opts.Username + if len(vm.RemoteUsername) < 1 { + vm.RemoteUsername = defaultRemoteUser + } + + // Add a random port for ssh + port, err := specgen.GetRandomPort() + if err != nil { + return nil, err + } + vm.Port = port + + vm.CPUs = opts.CPUS + vm.Memory = opts.Memory + + // Look up the executable + execPath, err := exec.LookPath(QemuCommand) + if err != nil { + return nil, err + } + cmd := append([]string{execPath}) + // Add memory + cmd = append(cmd, []string{"-m", strconv.Itoa(int(vm.Memory))}...) + // Add cpus + // TODO + // Add ignition file + cmd = append(cmd, []string{"-fw_cfg", "name=opt/com.coreos/config,file=" + vm.IgnitionFilePath}...) + // Add qmp socket + monitor, err := NewQMPMonitor("unix", vm.Name, defaultQMPTimeout) + if err != nil { + return nil, err + } + vm.QMPMonitor = monitor + cmd = append(cmd, []string{"-qmp", monitor.Network + ":/" + monitor.Address + ",server,nowait"}...) + + // Add network + cmd = append(cmd, "-nic", "user,model=virtio,hostfwd=tcp::"+strconv.Itoa(vm.Port)+"-:22") + vm.CmdLine = cmd + fmt.Println("///") + return vm, nil +} + +// LoadByName reads a json file that describes a known qemu vm +// and returns a vm instance +func LoadVMByName(name string) (machine.VM, error) { + // TODO need to define an error relating to ErrMachineNotFound + vm := new(MachineVM) + vmConfigDir, err := machine.GetConfDir(vmtype) + if err != nil { + return nil, err + } + b, err := ioutil.ReadFile(filepath.Join(vmConfigDir, name+".json")) + if err != nil { + return nil, err + } + err = json.Unmarshal(b, vm) + logrus.Debug(vm.CmdLine) + return vm, err +} + +// Create writes the json configuration file to the filesystem for +// other verbs (start, stop) +func (v *MachineVM) Create(opts machine.CreateOptions) error { + sshDir := filepath.Join(homedir.Get(), ".ssh") + // GetConfDir creates the directory so no need to check for + // its existence + vmConfigDir, err := machine.GetConfDir(vmtype) + if err != nil { + return err + } + jsonFile := filepath.Join(vmConfigDir, v.Name) + ".json" + v.IdentityPath = filepath.Join(sshDir, v.Name) + + dd, err := machine.NewFcosDownloader(vmtype, v.Name) + if err != nil { + return err + } + + v.ImagePath = dd.Get().LocalUncompressedFile + if err := dd.DownloadImage(); err != nil { + return err + } + // Add arch specific options including image location + v.CmdLine = append(v.CmdLine, v.addArchOptions()...) + + // Add location of bootable image + v.CmdLine = append(v.CmdLine, "-drive", "if=virtio,file="+v.ImagePath) + // This kind of stinks but no other way around this r/n + uri := machine.SSHRemoteConnection.MakeSSHURL("localhost", "/run/user/1000/podman/podman.sock", strconv.Itoa(v.Port), v.RemoteUsername) + if err := machine.AddConnection(&uri, v.Name, filepath.Join(sshDir, v.Name), opts.IsDefault); err != nil { + return err + } + // Write the JSON file + b, err := json.MarshalIndent(v, "", " ") + if err != nil { + return err + } + if err := ioutil.WriteFile(jsonFile, b, 0644); err != nil { + return err + } + key, err := machine.CreateSSHKeys(v.IdentityPath) + if err != nil { + return err + } + // Run arch specific things that need to be done + if err := v.prepare(); err != nil { + return err + } + // Write the ignition file + return machine.NewIgnitionFile(opts.Username, key, v.IgnitionFilePath) +} + +// Start executes the qemu command line and forks it +func (v *MachineVM) Start(name string, _ machine.StartOptions) error { + var ( + err error + ) + attr := new(os.ProcAttr) + files := []*os.File{os.Stdin, os.Stdout, os.Stderr} + attr.Files = files + fmt.Print(v.CmdLine) + _, err = os.StartProcess(v.CmdLine[0], v.CmdLine, attr) + return err +} + +// Stop uses the qmp monitor to call a system_powerdown +func (v *MachineVM) Stop(name string, _ machine.StopOptions) error { + // check if the qmp socket is there. if not, qemu instance is gone + if _, err := os.Stat(v.QMPMonitor.Address); os.IsNotExist(err) { + // Right now it is NOT an error to stop a stopped machine + logrus.Debugf("QMP monitor socket %v does not exist", v.QMPMonitor.Address) + return nil + } + qmpMonitor, err := qmp.NewSocketMonitor(v.QMPMonitor.Network, v.QMPMonitor.Address, v.QMPMonitor.Timeout) + if err != nil { + return err + } + // Simple JSON formation for the QAPI + stopCommand := struct { + Execute string `json:"execute"` + }{ + Execute: "system_powerdown", + } + input, err := json.Marshal(stopCommand) + if err != nil { + return err + } + if err := qmpMonitor.Connect(); err != nil { + return err + } + defer func() { + if err := qmpMonitor.Disconnect(); err != nil { + logrus.Error(err) + } + }() + _, err = qmpMonitor.Run(input) + return err +} + +// NewQMPMonitor creates the monitor subsection of our vm +func NewQMPMonitor(network, name string, timeout time.Duration) (Monitor, error) { + rtDir, err := getDataDir() + if err != nil { + return Monitor{}, err + } + if timeout == 0 { + timeout = defaultQMPTimeout + } + monitor := Monitor{ + Network: network, + Address: filepath.Join(rtDir, "podman", "qmp_"+name+".sock"), + Timeout: timeout, + } + return monitor, nil +} + +func (v *MachineVM) Destroy(name string, opts machine.DestroyOptions) (string, func() error, error) { + var ( + files []string + ) + + // cannot remove a running vm + if v.isRunning() { + return "", nil, errors.Errorf("running vm %q cannot be destroyed", v.Name) + } + + // Collect all the files that need to be destroyed + if !opts.SaveKeys { + files = append(files, v.IdentityPath, v.IdentityPath+".pub") + } + if !opts.SaveIgnition { + files = append(files, v.IgnitionFilePath) + } + if !opts.SaveImage { + files = append(files, v.ImagePath) + } + files = append(files, v.archRemovalFiles()...) + + if err := machine.RemoveConnection(v.Name); err != nil { + logrus.Error(err) + } + vmConfigDir, err := machine.GetConfDir(vmtype) + if err != nil { + return "", nil, err + } + files = append(files, filepath.Join(vmConfigDir, v.Name+".json")) + confirmationMessage := "\nThe following files will be deleted:\n\n" + for _, msg := range files { + confirmationMessage += msg + "\n" + } + confirmationMessage += "\n" + return confirmationMessage, func() error { + for _, f := range files { + if err := os.Remove(f); err != nil { + logrus.Error(err) + } + } + return nil + }, nil +} + +func (v *MachineVM) isRunning() bool { + // Check if qmp socket path exists + if _, err := os.Stat(v.QMPMonitor.Address); os.IsNotExist(err) { + return false + } + // Check if we can dial it + if _, err := qmp.NewSocketMonitor(v.QMPMonitor.Network, v.QMPMonitor.Address, v.QMPMonitor.Timeout); err != nil { + return false + } + return true +} + +// SSH opens an interactive SSH session to the vm specified. +// Added ssh function to VM interface: pkg/machine/config/go : line 58 +func (v *MachineVM) SSH(name string, opts machine.SSHOptions) error { + if !v.isRunning() { + return errors.Errorf("vm %q is not running.", v.Name) + } + + sshDestination := v.RemoteUsername + "@localhost" + port := strconv.Itoa(v.Port) + + fmt.Printf("Connecting to vm %s. To close connection, use `~.` or `exit`\n", v.Name) + + cmd := exec.Command("ssh", "-i", v.IdentityPath, "-p", port, sshDestination) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + + return cmd.Run() +} diff --git a/pkg/machine/qemu/options_amd64.go b/pkg/machine/qemu/options_amd64.go new file mode 100644 index 000000000..85a2c4b3e --- /dev/null +++ b/pkg/machine/qemu/options_amd64.go @@ -0,0 +1,26 @@ +package qemu + +import ( + "github.com/containers/podman/v3/pkg/util" +) + +var ( + QemuCommand = "qemu-kvm" +) + +func (v *MachineVM) addArchOptions() []string { + opts := []string{"-cpu", "host"} + return opts +} + +func (v *MachineVM) prepare() error { + return nil +} + +func (v *MachineVM) archRemovalFiles() []string { + return []string{} +} + +func getDataDir() (string, error) { + return util.GetRuntimeDir() +} diff --git a/pkg/machine/qemu/options_arm64.go b/pkg/machine/qemu/options_arm64.go new file mode 100644 index 000000000..c5b0ea16b --- /dev/null +++ b/pkg/machine/qemu/options_arm64.go @@ -0,0 +1,40 @@ +package qemu + +import ( + "os/exec" + "path/filepath" +) + +var ( + QemuCommand = "qemu-system-aarch64" +) + +func (v *MachineVM) addArchOptions() []string { + ovmfDir := getOvmfDir(v.ImagePath, v.Name) + opts := []string{ + "-accel", "hvf", + "-cpu", "cortex-a57", + "-M", "virt,highmem=off", + "-drive", "file=/usr/local/share/qemu/edk2-aarch64-code.fd,if=pflash,format=raw,readonly=on", + "-drive", "file=" + ovmfDir + ",if=pflash,format=raw"} + return opts +} + +func (v *MachineVM) prepare() error { + ovmfDir := getOvmfDir(v.ImagePath, v.Name) + cmd := []string{"dd", "if=/dev/zero", "conv=sync", "bs=1m", "count=64", "of=" + ovmfDir} + return exec.Command(cmd[0], cmd[1:]...).Run() +} + +func (v *MachineVM) archRemovalFiles() []string { + ovmDir := getOvmfDir(v.ImagePath, v.Name) + return []string{ovmDir} +} + +func getDataDir() (string, error) { + return "/tmp", nil +} + +func getOvmfDir(imagePath, vmName string) string { + return filepath.Join(filepath.Dir(imagePath), vmName+"_ovmf_vars.fd") +} |