initial commit

Signed-off-by: Akihiro Suda <akihiro.suda.cz@hco.ntt.co.jp>
This commit is contained in:
Akihiro Suda committed 2021-05-14 16:28:44 +09:00
commit 7459f45879
61 files changed
+4921

No files matched your search

+50
View File
@@ -0,0 +1,50 @@
# Forked from https://github.com/containerd/nerdctl/blob/v0.8.1/.github/workflows/release.yml
# Apache License 2.0
name: Release
on:
push:
tags:
- 'v*'
- 'test-action-release-*'
env:
GO111MODULE: on
jobs:
release:
runs-on: ubuntu-20.04
timeout-minutes: 20
steps:
- uses: actions/setup-go@v2
with:
go-version: 1.16.x
- uses: actions/checkout@v2
- name: "Compile binaries"
run: make artifacts
- name: "SHA256SUMS"
run: |
( cd _artifacts; sha256sum *.tar.gz ) | tee /tmp/SHA256SUMS
mv /tmp/SHA256SUMS _artifacts/SHA256SUMS
- name: "The sha256sum of the SHA256SUMS file"
run: (cd _artifacts; sha256sum SHA256SUMS)
- name: "Prepare the release note"
run: |
tag="${GITHUB_REF##*/}"
shasha=$(sha256sum _artifacts/SHA256SUMS | awk '{print $1}')
cat <<-EOF | tee /tmp/release-note.txt
${tag}
(Changes to be documented)
- - -
The binaries were built automatically on GitHub Actions.
The build log is available for 90 days: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
The sha256sum of the SHA256SUMS file itself is \`${shasha}\` .
EOF
- name: "Create release"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
tag="${GITHUB_REF##*/}"
asset_flags=()
for f in _artifacts/*; do asset_flags+=("-a" "$f"); done
hub release create "${asset_flags[@]}" -F /tmp/release-note.txt --draft "${tag}"
+84
View File
@@ -0,0 +1,84 @@
name: test
on:
push:
branches:
- master
- 'release/**'
pull_request:
jobs:
project:
name: Project Checks
runs-on: ubuntu-20.04
timeout-minutes: 20
steps:
- uses: actions/setup-go@v2
with:
go-version: 1.16.x
- uses: actions/checkout@v2
with:
fetch-depth: 25
- name: "Check DCO sign"
run: |
go get -u github.com/vbatts/git-validation
./hack/validate-dco.sh
golangci-lint:
runs-on: macos-10.15
timeout-minutes: 20
steps:
- uses: actions/checkout@v2
with:
fetch-depth: 1
- name: golangci-lint
uses: golangci/golangci-lint-action@v2
with:
version: v1.35
args: --verbose
unit:
name: Unit tests
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [macos-10.15, ubuntu-20.04]
timeout-minutes: 20
steps:
- uses: actions/setup-go@v2
with:
go-version: 1.16.x
- uses: actions/checkout@v2
with:
fetch-depth: 25
- name: Unit tests
run: go test -v ./...
integration:
name: Integration (WIP)
runs-on: macos-10.15
timeout-minutes: 20
steps:
- uses: actions/setup-go@v2
with:
go-version: 1.16.x
- uses: actions/checkout@v2
with:
fetch-depth: 25
- name: Make
run: make
artifacts:
name: Artifacts
# the release pipeline uses Linux, so we Linux here as well
runs-on: ubuntu-20.04
timeout-minutes: 20
steps:
- uses: actions/setup-go@v2
with:
go-version: 1.16.x
- uses: actions/checkout@v2
with:
fetch-depth: 25
- name: Make artifacts
run: make artifacts
+3
View File
@@ -0,0 +1,3 @@
_output/
_artifacts/
lima.REJECTED.yaml
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
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.
+62
View File
@@ -0,0 +1,62 @@
GO ?= go
TAR ?= tar
PACKAGE := github.com/AkihiroSuda/lima
VERSION=$(shell git describe --match 'v[0-9]*' --dirty='.m' --always --tags)
VERSION_TRIMMED := $(VERSION:v%=%)
GO_BUILD := CGO_ENABLED=0 $(GO) build -ldflags="-s -w -X $(PACKAGE)/pkg/version.Version=$(VERSION)"
.PHONY: all
all: binaries
.PHONY: binaries
binaries: \
_output/bin/lima \
_output/bin/limactl \
_output/bin/nerdctl.lima \
_output/share/lima/lima-guestagent.Linux-x86_64 \
_output/share/lima/lima-guestagent.Linux-aarch64
.PHONY: _output/bin/lima
_output/bin/lima:
mkdir -p _output/bin
cp -a ./cmd/lima $@
.PHONY: _output/bin/nerdctl.lima
_output/bin/nerdctl.lima:
mkdir -p _output/bin
cp -a ./cmd/nerdctl.lima $@
.PHONY: _output/bin/limactl
_output/bin/limactl:
$(GO_BUILD) -o $@ ./cmd/limactl
.PHONY: _output/share/lima/lima-guestagent.Linux-x86_64
_output/share/lima/lima-guestagent.Linux-x86_64:
GOOS=linux GOARCH=amd64 $(GO_BUILD) -o $@ ./cmd/lima-guestagent
chmod 644 $@
.PHONY: _output/share/lima/lima-guestagent.Linux-aarch64
_output/share/lima/lima-guestagent.Linux-aarch64:
GOOS=linux GOARCH=arm64 $(GO_BUILD) -o $@ ./cmd/lima-guestagent
chmod 644 $@
.PHONY: install
install:
cp -av _output/* /usr/local/
if [ ! -e /usr/local/bin/nerdctl ]; then ln -sf nerdctl.lima /usr/local/bin/nerdctl; fi
.PHONY: clean
clean:
rm -rf _output
.PHONY: artifacts
artifacts:
mkdir -p _artifacts
GOOS=darwin GOARCH=amd64 make clean binaries
$(TAR) -C _output/ -czvf _artifacts/lima-$(VERSION_TRIMMED)-Darwin-x86_64.tar.gz ./
GOOS=darwin GOARCH=arm64 make clean binaries
$(TAR) -C _output -czvf _artifacts/lima-$(VERSION_TRIMMED)-Darwin-arm64.tar.gz ./
+223
View File
@@ -0,0 +1,223 @@
# Lima: Linux-on-Mac ("macOS subsystem for Linux", "containerd for Mac")
Lima launches Linux virtual machines on macOS, with automatic file sharing, port forwarding,
and [containerd](https://containerd.io).
Lima can be considered as a some sort of unofficial "macOS subsystem for Linux", or "containerd for Mac".
✅ Automatic file sharing
✅ Automatic port forwarding
✅ Built-in support for [containerd](https://containerd.io)
✅ Intel on Intel
✅ ARM on Intel
✅ ARM on ARM (untested)
✅ Intel on ARM (untested)
✅ Ubuntu
✅ Fedora
Related project: [sshocker (ssh with file sharing and port forwarding)](https://github.com/AkihiroSuda/sshocker)
## Examples
### uname
```console
$ uname -a
Darwin macbook.local 20.4.0 Darwin Kernel Version 20.4.0: Thu Apr 22 21:46:47 PDT 2021; root:xnu-7195.101.2~1/RELEASE_X86_64 x86_64
$ lima uname -a
Linux lima-default 5.11.0-16-generic #17-Ubuntu SMP Wed Apr 14 20:12:43 UTC 2021 x86_64 x86_64 x86_64 GNU/Linux
$ LIMA_INSTANCE=arm lima uname -a
Linux lima-arm 5.11.0-16-generic #17-Ubuntu SMP Wed Apr 14 20:10:16 UTC 2021 aarch64 aarch64 aarch64 GNU/Linux
```
### Sharing files across macOS and Linux
```console
$ echo "files under /Users on macOS filesystem are readable from Linux" > some-file
$ lima cat some-file
files under /Users on macOS filesystem are readable from Linux
$ lima sh -c 'echo "/tmp/lima is writable from both macOS and Linux" > /tmp/lima/another-file'
$ cat /tmp/lima/another-file
/tmp/lima is writable from both macOS and Linux"
```
### Running containerd containers (compatible with Docker containers)
```console
$ lima nerdctl run -d --name nginx -p 127.0.0.1:8080:80 nginx:alpine
```
http://127.0.0.1:8080 is accessible from both macOS and Linux.
> **NOTE**
> Privileged ports (0-1023) cannot be forwarded
For the usage of containerd and nerdctl (contaiNERD ctl), visit https://github.com/containerd/containerd and https://github.com/containerd/nerdctl.
## Getting started
### Requirements (Intel Mac)
- QEMU (`brew install qemu`)
- Run the following commands to enable `--accel=hvf`:
```bash
cat >entitlements.xml <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- for OS X 10.10 - macOS 10.15 -->
<key>com.apple.vm.hypervisor</key>
<true/>
<!-- for macOS 11 and later -->
<key>com.apple.security.hypervisor</key>
<true/>
</dict>
</plist>
EOF
codesign -s - --entitlements entitlements.xml --force /usr/local/bin/qemu-system-x86_64
```
### Requirements (ARM Mac)
- QEMU with `--accel=hvf` support, see https://gist.github.com/citruz/9896cd6fb63288ac95f81716756cb9aa
> **NOTE**
> Lima is not tested on ARM Mac.
### Install
Download the binary archive from https://github.com/AkihiroSuda/lima/releases ,
and extract it under `/usr/local` (or somewhere else).
To install from the source, run `make && make install`.
### Usage
- Run `limactl start <INSTANCE>` to start the Linux instance.
The default instance name is "default".
Lima automatically opens an editor (`vi`) for reviewing and modifying the configuration.
Wait until "READY" to be printed on the host terminal.
- Run `limactl shell <INSTANCE> <COMMAND>` to launch `<COMMAND>` on Linux.
For the "default" instance, this command can be shortened as just `lima <COMMAND>`.
The `lima` command also accepts the instance name as the environment variable `$LIMA_INSTANCE`.
- Run `limactl ls` to show the instances.
- Run `limactl delete <INSTANCE>` to delete the instance.
- To enable bash completion, add `source <(limactl completion bash)` to `~/.bash_profile`.
### :warning: CAUTION: make sure to back up your data
Lima may have bugs that result in loss of data.
**Make sure to back up your data before running Lima.**
Especially, the following data might be easily lost:
- Data in the shared writable directories (`/tmp/lima` by default),
probably after hibernation of the host machine (e.g., after closing and reopening the laptop lid)
- Data in the VM image, mostly when upgrading the version of lima
### Configuration
See [`./pkg/limayaml/default.TEMPLATE.yaml`](./pkg/limayaml/default.TEMPLATE.yaml).
The current default spec:
- OS: Ubuntu 21.04 (Hirsute Hippo)
- CPU (x86\_64): Haswell v4, 4 cores
- CPU (aarch64): Cortex A72, 4 cores
- Memory: 4 GiB
- Disk: 100 GiB
- Mounts: `~` (read-only), `/tmp/lima` (writable)
- SSH: 127.0.0.1:60022
## How it works
- Hypervisor: QEMU with HVF accelerator
- Filesystem sharing: [reverse sshfs](https://github.com/AkihiroSuda/sshocker/blob/v0.1.0/pkg/reversesshfs/reversesshfs.go)
- Port forwarding: `ssh -L`, automated by watching `/proc/net/tcp` in the guest
## Developer guide
### Contributing to Lima
- Please certify your [Developer Certificate of Origin (DCO)](https://developercertificate.org/),
by signing off your commit with `git commit -s` and with your real name.
- Please squash commits.
### Help wanted
:pray:
- Test on ARM Mac
- Performance optimization
- Homebrew
- More guest distros
- Linux on Windows/Linux/BSD
- GUI with system tray icon (Qt or Electron, for portability)
- VirtFS to replace the current reverse sshfs (work has to be done on QEMU repo)
- [vsock](https://github.com/apple/darwin-xnu/blob/xnu-7195.81.3/bsd/man/man4/vsock.4) to replace SSH (work has to be done on QEMU repo)
## FAQs
### "What's my login password?"
Password is disabled and locked by default.
You have to use `limactl shell bash` (or `lima bash`) to open a shell.
Alternatively, you may also directly ssh into the guest: `ssh -p 60022 -o NoHostAuthenticationForLocalhost=yes 127.0.0.1`.
### "Does Lima work on ARM Mac?"
Yes, it should work, but not tested on ARM.
### "Can I run non-Ubuntu guests?"
Fedora is also known to work, see [`./examples/fedora.yaml`](./examples/fedora.yaml).
This file can be loaded with `limactl start ./examples/fedora.yaml`.
An image has to satisfy the following requirements:
- systemd
- cloud-init
- The following binaries to be preinstalled:
- `curl`
- `sudo`
- The following binaries to be preinstalled, or installable via the package manager:
- `sshfs`
- `newuidmap` and `newgidmap`
- `apt-get` or `dnf` (if you want to contribute support for another package manager, run `git grep apt-get` to find out where to modify)
### "Can I run other container engines such as Podman?"
Yes, if you install it.
containerd can be stopped with `systemctl --user disable --now containerd`.
### "Can I run Lima with a remote Linux machine?"
Lima itself does not support connecting to a remote Linux machine, but [sshocker](https://github.com/AkihiroSuda/sshocker),
the predecessor or Lima, provides similar features for remote Linux machines.
e.g., run `sshocker -v /Users/foo:/home/foo/mnt -p 8080:80 <USER>@<HOST>` to expose `/Users/foo` to the remote machine as `/home/foo/mnt`,
and forward `localhost:8080` to the port 80 of the remote machine.
### "QEMU crashes with `HV_ERROR`"
You have to add `com.apple.security.hypervisor` entitlement to `qemu-system-x86_64` binary.
See [Getting started](#getting-started).
### "QEMU is slow"
- Make sure that HVF is enabled with `com.apple.security.hypervisor` entitlement. See [Getting started](#getting-started).
- Emulating non-native machines (ARM-on-Intel, Intel-on-ARM) is slow by design.
### "Port forwarding does not work"
Privileged ports (0-1023) cannot be forwarded. e.g., you have to use 8080, not 80.
### "Hints for debugging other problems?"
- Inspect logs:
- `limactl --debug start`
- `/var/log/cloud-init-output.log` (inside the guest)
- `/var/log/cloud-init.log` (inside the guest)
- Make sure that you aren't mixing up tabs and spaces in the YAML.
- If you have passphrases for any private key under `~/.ssh`, you will need to have `ssh-agent` running.
Executable
+10
View File
@@ -0,0 +1,10 @@
#!/bin/sh
set -eu
: "${LIMA_INSTANCE:=default}"
LIMA="$(realpath "$0")"
LIMA_BINDIR="$(dirname "$LIMA")"
LIMACTL="${LIMA_BINDIR}/limactl"
if [ -d "$LIMACTL" ]; then
LIMACTL="limactl"
fi
exec "$LIMACTL" shell "$LIMA_INSTANCE" "$@"
+82
View File
@@ -0,0 +1,82 @@
package main
import (
"errors"
"net"
"net/http"
"os"
"path/filepath"
"time"
"github.com/AkihiroSuda/lima/pkg/guestagent"
"github.com/AkihiroSuda/lima/pkg/guestagent/api/server"
"github.com/gorilla/mux"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
)
var daemonCommand = &cli.Command{
Name: "daemon",
Usage: "run the daemon",
ArgsUsage: "[SOCKET]",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "socket",
Usage: "socket",
Value: func() string {
if xrd := os.Getenv("XDG_RUNTIME_DIR"); xrd != "" {
return filepath.Join(xrd, "lima-guestagent.sock")
}
logrus.Warn("$XDG_RUNTIME_DIR is not set, cannot determine the socket name")
return ""
}(),
},
&cli.DurationFlag{
Name: "tick",
Usage: "tick for polling events",
Value: 3 * time.Second,
},
},
Action: daemonAction,
}
func daemonAction(clicontext *cli.Context) error {
socket := clicontext.String("socket")
if socket == "" {
return errors.New("socket must be specified")
}
tick := clicontext.Duration("tick")
if tick == 0 {
return errors.New("tick must be specified")
}
if os.Geteuid() == 0 {
return errors.New("must not run as the root")
}
logrus.Infof("event tick: %v", tick)
newTicker := func() (<-chan time.Time, func()) {
// TODO: use an equivalent of `bpftrace -e 'tracepoint:syscalls:sys_*_bind { printf("tick\n"); }')`,
// without depending on `bpftrace` binary.
// The agent binary will need CAP_BPF file cap.
ticker := time.NewTicker(tick)
return ticker.C, ticker.Stop
}
agent := guestagent.New(newTicker)
backend := &server.Backend{
Agent: agent,
}
r := mux.NewRouter()
server.AddRoutes(r, backend)
srv := &http.Server{Handler: r}
err := os.RemoveAll(socket)
if err != nil {
return err
}
l, err := net.Listen("unix", socket)
if err != nil {
return err
}
logrus.Infof("serving the guest agent on %q", socket)
return srv.Serve(l)
}
@@ -0,0 +1,81 @@
package main
import (
_ "embed"
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/AkihiroSuda/lima/pkg/templateutil"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
)
var installSystemdCommand = &cli.Command{
Name: "install-systemd",
Usage: "install a systemd unit (user)",
Action: installSystemdAction,
}
func installSystemdAction(clicontext *cli.Context) error {
unit, err := generateSystemdUnit()
if err != nil {
return err
}
unitPath, err := systemdUnitPath()
if err != nil {
return err
}
if _, err := os.Stat(unitPath); !errors.Is(err, os.ErrNotExist) {
logrus.Infof("File %q already exists, overwriting", unitPath)
} else {
unitDir := filepath.Dir(unitPath)
if err := os.MkdirAll(unitDir, 0755); err != nil {
return err
}
}
if err := os.WriteFile(unitPath, unit, 0644); err != nil {
return err
}
logrus.Infof("Written file %q", unitPath)
argss := [][]string{
{"daemon-reload"},
{"enable", "--now", "lima-guestagent.service"},
}
for _, args := range argss {
cmd := exec.Command("systemctl", append([]string{"--user"}, args...)...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
logrus.Infof("Executing: %s", strings.Join(cmd.Args, " "))
if err := cmd.Run(); err != nil {
return err
}
}
logrus.Info("Done")
return nil
}
func systemdUnitPath() (string, error) {
configDir, err := os.UserConfigDir()
if err != nil {
return "", err
}
unitPath := filepath.Join(configDir, "systemd/user/lima-guestagent.service")
return unitPath, nil
}
//go:embed lima-guestagent.TEMPLATE.service
var systemdUnitTemplate string
func generateSystemdUnit() ([]byte, error) {
selfExeAbs, err := os.Executable()
if err != nil {
return nil, err
}
m := map[string]string{
"Binary": selfExeAbs,
}
return templateutil.Execute(systemdUnitTemplate, m)
}
@@ -0,0 +1,10 @@
[Unit]
Description=lima-guestagent
[Service]
ExecStart={{.Binary}} daemon
Type=simple
Restart=on-failure
[Install]
WantedBy=default.target
+44
View File
@@ -0,0 +1,44 @@
package main
import (
"errors"
"os"
"strings"
"github.com/AkihiroSuda/lima/pkg/version"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
)
func main() {
if err := newApp().Run(os.Args); err != nil {
logrus.Fatal(err)
}
}
func newApp() *cli.App {
app := cli.NewApp()
app.Name = "lima-guestagent"
app.Usage = "Do not launch manually"
app.Version = strings.TrimPrefix(version.Version, "v")
app.Flags = []cli.Flag{
&cli.BoolFlag{
Name: "debug",
Usage: "debug mode",
},
}
app.Before = func(clicontext *cli.Context) error {
if clicontext.Bool("debug") {
logrus.SetLevel(logrus.DebugLevel)
}
if os.Geteuid() == 0 {
return errors.New("must not run as the root")
}
return nil
}
app.Commands = []*cli.Command{
daemonCommand,
installSystemdCommand,
}
return app
}
+82
View File
@@ -0,0 +1,82 @@
// Forked from https://github.com/containerd/nerdctl/blob/v0.8.1/completion.go
/*
Copyright The containerd Authors.
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 main
import (
"fmt"
"github.com/AkihiroSuda/lima/pkg/store"
"github.com/urfave/cli/v2"
)
var completionCommand = &cli.Command{
Name: "completion",
Usage: "Show shell completion",
Subcommands: []*cli.Command{
completionBashCommand,
},
}
var completionBashCommand = &cli.Command{
Name: "bash",
Usage: "Show bash completion (use with `source <(limactl completion bash)`)",
Description: "Usage: add `source <(limactl completion bash)` to ~/.bash_profile",
Action: completionBashAction,
}
func completionBashAction(clicontext *cli.Context) error {
tmpl := `#!/bin/bash
# Autocompletion enabler for limactl.
# Usage: add 'source <(limactl completion bash)' to ~/.bash_profile
# _limactl_bash_autocomplete is forked from https://github.com/urfave/cli/blob/v2.3.0/autocomplete/bash_autocomplete (MIT License)
_limactl_bash_autocomplete() {
if [[ "${COMP_WORDS[0]}" != "source" ]]; then
local cur opts base
COMPREPLY=()
cur="${COMP_WORDS[COMP_CWORD]}"
local args="${COMP_WORDS[@]:0:$COMP_CWORD}"
# make {"limactl", "--foo", "=", "bar"} into {"limactl", "--foo=bar"}
args="$(echo $args | sed -e 's/ = /=/g')"
if [[ "$cur" == "-"* ]]; then
opts=$( ${args} ${cur} --generate-bash-completion )
else
opts=$( ${args} --generate-bash-completion )
fi
COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
return 0
fi
}
complete -o bashdefault -o default -o nospace -F _limactl_bash_autocomplete limactl
`
_, err := fmt.Fprint(clicontext.App.Writer, tmpl)
return err
}
func bashCompleteInstanceNames(clicontext *cli.Context) {
w := clicontext.App.Writer
instances, err := store.Instances()
if err != nil {
return
}
for _, name := range instances {
fmt.Fprintln(w, name)
}
}
+43
View File
@@ -0,0 +1,43 @@
package main
import (
"os"
"github.com/AkihiroSuda/lima/pkg/store"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
)
var deleteCommand = &cli.Command{
Name: "delete",
Usage: "Delete an instance of Lima.",
ArgsUsage: "INSTANCE [INSTANCE, ...]",
Action: deleteAction,
BashComplete: deleteBashComplete,
}
func deleteAction(clicontext *cli.Context) error {
if clicontext.NArg() == 0 {
return errors.Errorf("requires at least 1 argument")
}
for _, instName := range clicontext.Args().Slice() {
instDir, err := store.InstanceDir(instName)
if err != nil {
return err
}
if _, err := os.Stat(instDir); errors.Is(err, os.ErrNotExist) {
logrus.Warnf("Ignoring non-existent instance %q (%q)", instName, instDir)
return nil
}
if err := os.RemoveAll(instDir); err != nil {
return errors.Wrapf(err, "failed to remove %q", instDir)
}
logrus.Infof("Deleted %q (%q)", instName, instDir)
}
return nil
}
func deleteBashComplete(clicontext *cli.Context) {
bashCompleteInstanceNames(clicontext)
}
+46
View File
@@ -0,0 +1,46 @@
package main
import (
"fmt"
"text/tabwriter"
"github.com/AkihiroSuda/lima/pkg/store"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
)
var lsCommand = &cli.Command{
Name: "ls",
Usage: "List instances of Lima.",
// TODO: add --json flag for automation
Action: lsAction,
}
func lsAction(clicontext *cli.Context) error {
instances, err := store.Instances()
if err != nil {
return err
}
w := tabwriter.NewWriter(clicontext.App.Writer, 4, 8, 4, ' ', 0)
fmt.Fprintln(w, "NAME\tSSH\tARCH\tDIR")
if len(instances) == 0 {
logrus.Warn("No instance found. Run `limactl start` to create an instance.")
}
for _, instName := range instances {
y, instDir, err := store.LoadYAMLByInstanceName(instName)
if err != nil {
logrus.WithError(err).Warnf("failed to load instance %q", instName)
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n",
instName,
fmt.Sprintf("127.0.0.1:%d", y.SSH.LocalPort),
y.Arch,
instDir,
)
}
return w.Flush()
}
+64
View File
@@ -0,0 +1,64 @@
package main
import (
"errors"
"fmt"
"os"
"strings"
"github.com/AkihiroSuda/lima/pkg/version"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
)
func main() {
if err := newApp().Run(os.Args); err != nil {
logrus.Fatal(err)
}
}
func newApp() *cli.App {
app := cli.NewApp()
app.Name = "limactl"
app.Usage = `Lima: Linux-on-Mac ("macOS subsystem for Linux", "containerd for Mac")`
app.UseShortOptionHandling = true
app.EnableBashCompletion = true
app.BashComplete = appBashComplete
app.Version = strings.TrimPrefix(version.Version, "v")
app.Flags = []cli.Flag{
&cli.BoolFlag{
Name: "debug",
Usage: "debug mode",
},
}
app.Before = func(clicontext *cli.Context) error {
if clicontext.Bool("debug") {
logrus.SetLevel(logrus.DebugLevel)
}
if os.Geteuid() == 0 {
return errors.New("must not run as the root")
}
return nil
}
app.Commands = []*cli.Command{
startCommand,
// TODO: add stopCommand (stops an instance without deletion)
shellCommand,
lsCommand,
deleteCommand,
completionCommand,
}
return app
}
const (
DefaultInstanceName = "default"
)
func appBashComplete(clicontext *cli.Context) {
w := clicontext.App.Writer
cli.DefaultAppComplete(clicontext)
for _, subcomm := range clicontext.App.Commands {
fmt.Fprintln(w, subcomm.Name)
}
}
+110
View File
@@ -0,0 +1,110 @@
package main
import (
"fmt"
"os"
"os/exec"
"strconv"
"strings"
"github.com/AkihiroSuda/lima/pkg/sshutil"
"github.com/AkihiroSuda/lima/pkg/store"
"github.com/alessio/shellescape"
"github.com/mattn/go-isatty"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
)
var shellCommand = &cli.Command{
Name: "shell",
Usage: "Execute shell in Lima",
ArgsUsage: "[flags] INSTANCE [COMMAND...]",
Description: "`lima` command is provided as an alias for `limactl shell $LIMA_INSTANCE`. $LIMA_INSTANCE defaults to " + DefaultInstanceName + ".\n" +
"Hint: try --debug to show the detailed logs, if it seems hanging (mostly due to some SSH issue).",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "workdir",
Usage: "working directory",
Value: func() string {
wd, err := os.Getwd()
if err != nil {
logrus.WithError(err).Warn("failed to get the current directory")
home, err := os.UserHomeDir()
if err != nil {
logrus.WithError(err).Warn("failed to get the home directory")
return "/"
}
return home
}
return wd
}(),
},
},
Action: shellAction,
BashComplete: shellBashComplete,
}
func shellAction(clicontext *cli.Context) error {
if clicontext.NArg() == 0 {
return errors.Errorf("requires at least 1 argument")
}
instName := clicontext.Args().First()
switch clicontext.Args().Get(1) {
case "start", "delete", "shell":
// `lima start` (alias of `limactl $LIMA_INSTANCE start`) is probably a typo of `limactl start`
logrus.Warnf("Perhaps you meant `limactl %s`?", strings.Join(clicontext.Args().Slice(), " "))
}
hostHome, err := os.UserHomeDir()
if err != nil {
return err
}
script := fmt.Sprintf(" cd %q || cd %q ; exec bash --login", clicontext.String("workdir"), hostHome)
if clicontext.NArg() > 1 {
script += fmt.Sprintf(" -c %q", shellescape.QuoteCommand(clicontext.Args().Tail()))
}
y, instDir, err := store.LoadYAMLByInstanceName(instName)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return errors.Errorf("instance %q does not exist, run `limactl start %s` to create a new instance", instName, instName)
}
return err
}
arg0, err := exec.LookPath("ssh")
if err != nil {
return err
}
args, err := sshutil.SSHArgs(instDir)
if err != nil {
return err
}
if isatty.IsTerminal(os.Stdout.Fd()) {
// required for showing the shell prompt: https://stackoverflow.com/a/626574
args = append(args, "-t")
}
args = append(args, []string{
"-q",
"-p", strconv.Itoa(y.SSH.LocalPort),
"127.0.0.1",
"--",
script,
}...)
cmd := exec.Command(arg0, args...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
logrus.Debugf("executing ssh (may take a long)): %+v", cmd.Args)
// TODO: use syscall.Exec directly (results in losing tty?)
return cmd.Run()
}
func shellBashComplete(clicontext *cli.Context) {
bashCompleteInstanceNames(clicontext)
}
+202
View File
@@ -0,0 +1,202 @@
package main
import (
"fmt"
"io/ioutil"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/AkihiroSuda/lima/pkg/limayaml"
"github.com/AkihiroSuda/lima/pkg/start"
"github.com/AkihiroSuda/lima/pkg/store"
"github.com/containerd/containerd/identifiers"
"github.com/mattn/go-isatty"
"github.com/norouter/norouter/cmd/norouter/editorcmd"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
)
var startCommand = &cli.Command{
Name: "start",
Usage: fmt.Sprintf("Start an instance of Lima. If the instance does not exist, open an editor for creating new one, with name %q", DefaultInstanceName),
ArgsUsage: "[flags] NAME|FILE.yaml",
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "tty",
Usage: "enable TUI interactions such as opening an editor, defaults to true when stdout is a terminal",
Value: isatty.IsTerminal(os.Stdout.Fd()),
},
},
Action: startAction,
BashComplete: startBashComplete,
}
func loadOrCreateYAML(clicontext *cli.Context) (y *limayaml.LimaYAML, instDir string, err error) {
if clicontext.NArg() > 1 {
return nil, "", errors.Errorf("too many arguments")
}
arg := clicontext.Args().First()
if arg == "" {
arg = DefaultInstanceName
}
yBytes := limayaml.DefaultTemplate
var instName string
if argSeemsYAMLPath(arg) {
instName = instNameFromYAMLPath(arg)
logrus.Debugf("interpreting argument %q as a file path for instance %q", arg, instName)
yBytes, err = os.ReadFile(arg)
if err != nil {
return nil, "", err
}
} else {
instName = arg
logrus.Debugf("interpreting argument %q as an instance name %q", arg, instName)
if err = identifiers.Validate(instName); err != nil {
return nil, "", errors.Wrapf(err, "argument must be either an instance name or a YAML file path, got %q", instName)
}
y, instDir, err = store.LoadYAMLByInstanceName(instName)
if err == nil {
logrus.Infof("Using the existing instance %q", instName)
return y, instDir, nil
} else {
if !errors.Is(err, os.ErrNotExist) {
return nil, "", err
}
}
}
// create a new instance from the template
instDir, err = store.InstanceDir(instName)
if err != nil {
return nil, "", err
}
if _, err := os.Stat(instDir); !errors.Is(err, os.ErrNotExist) {
return nil, "", errors.Errorf("instance %q already exists (%q)", instName, instDir)
}
if clicontext.Bool("tty") {
yBytes, err = openEditor(clicontext, instName, yBytes)
if err != nil {
return nil, "", err
}
if len(yBytes) == 0 {
logrus.Info("Aborting, as requested by saving the file with empty content")
os.Exit(0)
return nil, "", errors.New("should not reach here")
}
} else {
logrus.Info("Terminal is not available, proceeding without opening an editor")
}
y, err = limayaml.Load(yBytes)
if err != nil {
return nil, "", err
}
if err := limayaml.Validate(*y); err != nil {
if !clicontext.Bool("tty") {
return nil, "", err
}
rejectedYAML := "lima.REJECTED.yaml"
if writeErr := os.WriteFile(rejectedYAML, yBytes, 0644); writeErr != nil {
return nil, "", errors.Wrapf(err, "the YAML is invalid, attempted to save the buffer as %q but failed: %v", rejectedYAML, writeErr)
}
return nil, "", errors.Wrapf(err, "the YAML is invalid, saved the buffer as %q", rejectedYAML)
}
if err := os.MkdirAll(instDir, 0700); err != nil {
return nil, "", err
}
if err := os.WriteFile(filepath.Join(instDir, store.YAMLFileName), yBytes, 0644); err != nil {
return nil, "", err
}
return y, instDir, nil
}
// openEditor opens an editor, and returns the content (not path) of the modified yaml.
//
// openEditor returns nil when the file was saved as an empty file, optionally with whitespaces.
func openEditor(clicontext *cli.Context, name string, initialContent []byte) ([]byte, error) {
editor := editorcmd.Detect()
if editor == "" {
return nil, errors.New("could not detect a text editor binary, try setting $EDITOR")
}
tmpYAMLFile, err := ioutil.TempFile("", "lima-editor-")
if err != nil {
return nil, err
}
tmpYAMLPath := tmpYAMLFile.Name()
defer os.RemoveAll(tmpYAMLPath)
hdr := fmt.Sprintf("# Review and modify the following configuration for Lima instance %q.\n", name)
if name == DefaultInstanceName {
hdr += "# - In most cases, you do not need to modify this file.\n"
}
hdr += "# - To cancel starting Lima, just save this file as an empty file.\n"
hdr += "\n"
if err := ioutil.WriteFile(tmpYAMLPath,
append([]byte(hdr), initialContent...),
0o600); err != nil {
return nil, err
}
editorCmd := exec.Command(editor, tmpYAMLPath)
editorCmd.Env = os.Environ()
editorCmd.Stdin = os.Stdin
editorCmd.Stdout = os.Stdout
editorCmd.Stderr = os.Stderr
logrus.Debugf("opening editor %q for a file %q", editor, tmpYAMLPath)
if err := editorCmd.Run(); err != nil {
return nil, errors.Wrapf(err, "could not execute editor %q for a file %q", editor, tmpYAMLPath)
}
b, err := os.ReadFile(tmpYAMLPath)
if err != nil {
return nil, err
}
modifiedInclHdr := string(b)
modifiedExclHdr := strings.TrimPrefix(modifiedInclHdr, hdr)
if strings.TrimSpace(modifiedExclHdr) == "" {
return nil, nil
}
return []byte(modifiedExclHdr), nil
}
func startAction(clicontext *cli.Context) error {
y, instDir, err := loadOrCreateYAML(clicontext)
if err != nil {
return err
}
instName, err := store.InstanceNameFromInstanceDir(instDir)
if err != nil {
return err
}
ctx := clicontext.Context
return start.Start(ctx, instName, instDir, y)
}
func argSeemsYAMLPath(arg string) bool {
if strings.Contains(arg, "/") {
return true
}
lower := strings.ToLower(arg)
return strings.HasSuffix(lower, ".yml") || strings.HasSuffix(lower, ".yaml")
}
func instNameFromYAMLPath(yamlPath string) string {
s := strings.ToLower(filepath.Base(yamlPath))
s = strings.TrimSuffix(strings.TrimSuffix(s, ".yml"), ".yaml")
s = strings.ReplaceAll(s, ".", "-")
if err := identifiers.Validate(s); err != nil {
logrus.WithField("candidate", s).WithError(err).
Warnf("failed to determine the name of the new instance from file path %q, using the default name %q",
yamlPath, DefaultInstanceName)
return DefaultInstanceName
}
return s
}
func startBashComplete(clicontext *cli.Context) {
bashCompleteInstanceNames(clicontext)
}
+3
View File
@@ -0,0 +1,3 @@
#!/bin/sh
set -eu
exec lima nerdctl "$@"
+13
View File
@@ -0,0 +1,13 @@
# Internal data structure
## Instance directory (`~/.lima/<INSTANCE>`)
An instance directory contains the following files:
- `lima.yaml`: the YAML
- `cidata.iso`: cloud-init ISO9660 image. (`user-data`, `meta-data`, `lima-guestagent.Linux-<ARCH>`)
- `basedisk`: the base image
- `diffdisk`: the diff image (QCOW2)
- `qemu-pid`: PID of the QEMU
- `ssh.sock`: SSH control master socket
- `ga.sock`: Forwarded to `/run/user/$UID/lima-guestagent.sock`
+5
View File
@@ -0,0 +1,5 @@
# Lima examples
Run `limactl start fedora.yaml` to create a Lima instance named "fedora".
To open a shell, run `limactl shell fedora bash` or `LIMA_INSTANCE=fedora lima bash`.
+1
View File
@@ -0,0 +1 @@
../pkg/limayaml/default.TEMPLATE.yaml
+16
View File
@@ -0,0 +1,16 @@
arch: "x86_64"
images:
- location: "https://download.fedoraproject.org/pub/fedora/linux/releases/34/Cloud/x86_64/images/Fedora-Cloud-Base-34-1.2.x86_64.qcow2"
arch: "x86_64"
mounts:
- location: "~"
writable: false
- location: "/tmp/lima"
writable: true
ssh:
# localPort is changed from 60022 to avoid conflicting with the default.
# (TODO: assign localPort automatically)
localPort: 60024
firmware:
legacyBIOS: true
+14
View File
@@ -0,0 +1,14 @@
images:
- location: "https://cloud-images.ubuntu.com/hirsute/current/hirsute-server-cloudimg-amd64.img"
arch: "x86_64"
- location: "https://cloud-images.ubuntu.com/hirsute/current/hirsute-server-cloudimg-arm64.img"
arch: "aarch64"
mounts:
- location: "~"
writable: false
- location: "/tmp/lima"
writable: true
ssh:
# localPort is changed from 60022 to avoid conflicting with the default.
# (TODO: assign localPort automatically)
localPort: 60023
+21
View File
@@ -0,0 +1,21 @@
module github.com/AkihiroSuda/lima
go 1.16
require (
github.com/AkihiroSuda/sshocker v0.1.1-0.20210510144941-56aa3c7472b0
github.com/alessio/shellescape v1.4.1
github.com/containerd/containerd v1.5.0
github.com/cyphar/filepath-securejoin v0.2.2
github.com/docker/go-units v0.4.0
github.com/gorilla/mux v1.8.0
github.com/hashicorp/go-multierror v1.1.1
github.com/mattn/go-isatty v0.0.12
github.com/norouter/norouter v0.6.3
github.com/pkg/errors v0.9.1
github.com/sirupsen/logrus v1.8.1
github.com/urfave/cli/v2 v2.3.0
github.com/yalue/native_endian v1.0.1
gopkg.in/yaml.v2 v2.4.0
gotest.tools/v3 v3.0.3
)
+1138
View File
File diff suppressed because it is too large. Load diff
+4
View File
@@ -0,0 +1,4 @@
#!/bin/bash
set -eux -o pipefail
# Depends on github.com/vbatts/git-validation"
git-validation -run DCO || { echo >&2 'ERROR: DCO sign must be added (git commit -a -s --amend)'; exit 1; }
+122
View File
@@ -0,0 +1,122 @@
package cidata
import (
"io/fs"
"os"
"os/user"
"path/filepath"
"strconv"
"github.com/AkihiroSuda/lima/pkg/iso9660util"
"github.com/AkihiroSuda/lima/pkg/limayaml"
"github.com/AkihiroSuda/lima/pkg/localpathutil"
"github.com/AkihiroSuda/lima/pkg/sshutil"
"github.com/pkg/errors"
)
func GenerateISO9660(name string, y *limayaml.LimaYAML) (*iso9660util.ISO9660, error) {
if err := limayaml.ValidateRaw(*y); err != nil {
return nil, err
}
u, err := user.Current()
if err != nil {
return nil, err
}
uid, err := strconv.Atoi(u.Uid)
if err != nil {
return nil, err
}
args := TemplateArgs{
Name: name,
User: u.Username,
UID: uid,
}
for _, f := range sshutil.DefaultPubKeys() {
args.SSHPubKeys = append(args.SSHPubKeys, f.Content)
}
for _, f := range y.Mounts {
expanded, err := localpathutil.Expand(f.Location)
if err != nil {
return nil, err
}
args.Mounts = append(args.Mounts, expanded)
}
if err := ValidateTemplateArgs(args); err != nil {
return nil, err
}
userData, err := GenerateUserData(args)
if err != nil {
return nil, err
}
metaData, err := GenerateMetaData(args)
if err != nil {
return nil, err
}
guestAgentBinaryPath, err := GuestAgentBinaryPath(y.Arch)
if err != nil {
return nil, err
}
iso9660 := &iso9660util.ISO9660{
Name: "cidata",
FilesFromContent: map[string]string{
"user-data": string(userData),
"meta-data": string(metaData),
},
FilesFromHostFilePath: map[string]string{
"lima-guestagent": guestAgentBinaryPath,
},
}
return iso9660, nil
}
func GuestAgentBinaryPath(arch string) (string, error) {
if arch == "" {
return "", errors.New("arch must be set")
}
self, err := os.Executable()
if err != nil {
return "", err
}
selfSt, err := os.Stat(self)
if err != nil {
return "", err
}
if selfSt.Mode()&fs.ModeSymlink != 0 {
self, err = os.Readlink(self)
if err != nil {
return "", err
}
}
// self: /usr/local/bin/limactl
selfDir := filepath.Dir(self)
selfDirDir := filepath.Dir(selfDir)
candidates := []string{
// candidate 0:
// - self: /Applications/Lima.app/Contents/MacOS/limactl
// - agent: /Applications/Lima.app/Contents/MacOS/lima-guestagent.Linux-x86_64
filepath.Join(selfDir, "lima-guestagent.Linux-"+arch),
// candidate 1:
// - self: /usr/local/bin/limactl
// - agent: /usr/local/share/lima/lima-guestagent.Linux-x86_64
filepath.Join(selfDirDir, "share/lima/lima-guestagent.Linux-"+arch),
// TODO: support custom path
}
for _, f := range candidates {
if _, err := os.Stat(f); err == nil {
return f, nil
} else if !errors.Is(err, os.ErrNotExist) {
return "", err
}
}
return "", errors.Errorf("failed to find \"lima-guestagent.Linux-%s\" binary for %q, attempted %v",
arch, self, candidates)
}
+1
View File
@@ -0,0 +1 @@
local-hostname: lima-{{.Name}}
+63
View File
@@ -0,0 +1,63 @@
package cidata
import (
_ "embed"
"path/filepath"
"github.com/AkihiroSuda/lima/pkg/templateutil"
"github.com/containerd/containerd/identifiers"
"github.com/pkg/errors"
)
var (
//go:embed user-data.TEMPLATE
userDataTemplate string
//go:embed meta-data.TEMPLATE
metaDataTemplate string
)
type TemplateArgs struct {
Name string // instance name
User string // user name
UID int
SSHPubKeys []string
Mounts []string // abs path, accessible by the User
}
func ValidateTemplateArgs(args TemplateArgs) error {
if err := identifiers.Validate(args.Name); err != nil {
return err
}
if err := identifiers.Validate(args.User); err != nil {
return err
}
if args.User == "root" {
return errors.New("field User must not be \"root\"")
}
if args.UID == 0 {
return errors.New("field UID must not be 0")
}
if len(args.SSHPubKeys) == 0 {
return errors.New("field SSHPubKeys must be set")
}
for i, f := range args.Mounts {
if !filepath.IsAbs(f) {
return errors.Errorf("field mounts[%d] must be absolute, got %q", i, f)
}
}
return nil
}
func GenerateUserData(args TemplateArgs) ([]byte, error) {
if err := ValidateTemplateArgs(args); err != nil {
return nil, err
}
return templateutil.Execute(userDataTemplate, args)
}
func GenerateMetaData(args TemplateArgs) ([]byte, error) {
if err := ValidateTemplateArgs(args); err != nil {
return nil, err
}
return templateutil.Execute(metaDataTemplate, args)
}
+29
View File
@@ -0,0 +1,29 @@
package cidata
import (
"testing"
"gotest.tools/v3/assert"
)
func TestTemplate(t *testing.T) {
args := TemplateArgs{
Name: "default",
User: "foo",
UID: 501,
SSHPubKeys: []string{
"ssh-rsa dummy foo@example.com",
},
Mounts: []string{
"/Users/dummy",
"/Users/dummy/lima",
},
}
userData, err := GenerateUserData(args)
assert.NilError(t, err)
t.Log(string(userData))
metaData, err := GenerateMetaData(args)
assert.NilError(t, err)
t.Log(string(metaData))
}
+109
View File
@@ -0,0 +1,109 @@
#cloud-config
# vim:syntax=yaml
growpart:
mode: auto
devices: ['/']
users:
- name: "{{.User}}"
uid: "{{.UID}}"
homedir: "/home/{{.User}}.linux"
shell: /bin/bash
sudo: ALL=(ALL) NOPASSWD:ALL
lock_passwd: true
ssh-authorized-keys:
{{range $val := .SSHPubKeys}}
- {{$val}}
{{end}}
write_files:
- content: |
#!/bin/bash
set -eux -o pipefail
# Enable rootless containers
if [ ! -e "/etc/systemd/system/user@.service.d/lima.conf" ]; then
mkdir -p "/etc/systemd/system/user@.service.d"
cat >"/etc/systemd/system/user@.service.d/lima.conf" <<EOF
[Service]
Delegate=yes
EOF
fi
systemctl daemon-reload
sysctl_conf="/etc/sysctl.d/99-lima.conf"
if [ ! -e "${sysctl_conf}" ]; then
if [ -e "/proc/sys/kernel/unprivileged_userns_clone" ]; then
echo "kernel.unprivileged_userns_clone=1" >> "${sysctl_conf}"
fi
echo "net.ipv4.ping_group_range = 0 2147483647" >> "${sysctl_conf}"
echo "net.ipv4.ip_unprivileged_port_start=0" >> "${sysctl_conf}"
sysctl --system
fi
# Do additional setup for the user
for f in /etc/subuid /etc/subgid; do
grep -qw "{{.User}}" $f || echo "{{.User}}:100000:65536" >> $f
done
loginctl enable-linger "{{.User}}"
# Create mount points
{{range $val := .Mounts}}
mkdir -p "{{$val}}"
chown "{{$.User}}" "{{$val}}" || true
{{end}}
# Install or update the guestagent binary
mkdir -p -m 600 /mnt/lima-cidata
mount -t iso9660 -o ro /dev/disk/by-label/cidata /mnt/lima-cidata
install -m 755 /mnt/lima-cidata/lima-guestagent /usr/local/bin/lima-guestagent
umount /mnt/lima-cidata
# Launch the guestagent service
until [ -e "/run/user/{{.UID}}/systemd/private" ]; do sleep 3; done
sudo -iu "{{.User}}" "XDG_RUNTIME_DIR=/run/user/{{.UID}}" lima-guestagent install-systemd
owner: root:root
# We do not use per-once.
path: /var/lib/cloud/scripts/per-boot/00-base.boot.sh
permissions: '0755'
- content: |
#!/bin/bash
set -eux -o pipefail
# Install minimum dependencies
if command -v apt-get 2>&1 >/dev/null; then
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y sshfs uidmap
elif command -v dnf 2>&1 >/dev/null; then
dnf install -y fuse-sshfs shadow-utils
fi
owner: root:root
path: /var/lib/cloud/scripts/per-boot/10-install-packages.boot.sh
permissions: '0755'
- content: |
#!/bin/bash
set -eux -o pipefail
modprobe tap || true
if [ ! -e "/home/{{.User}}.linux/.config/containerd/config.toml" ]; then
mkdir -p "/home/{{.User}}.linux/.config/containerd"
cat >"/home/{{.User}}.linux/.config/containerd/config.toml" <<EOF
[proxy_plugins]
[proxy_plugins."stargz"]
type = "snapshot"
address = "/run/user/{{.UID}}/containerd-stargz-grpc/containerd-stargz-grpc.sock"
EOF
chown -R "{{.User}}" "/home/{{.User}}.linux/.config"
fi
if [ ! -x /usr/local/bin/nerdctl -a "$(uname -m)" = "x86_64" ]; then
curl -fsSL https://github.com/containerd/nerdctl/releases/download/v0.8.1/nerdctl-full-0.8.1-linux-amd64.tar.gz | tar Cxz /usr/local
until [ -e "/run/user/{{.UID}}/systemd/private" ]; do sleep 3; done
sudo -iu "{{.User}}" "XDG_RUNTIME_DIR=/run/user/{{.UID}}" containerd-rootless-setuptool.sh install
sudo -iu "{{.User}}" "XDG_RUNTIME_DIR=/run/user/{{.UID}}" containerd-rootless-setuptool.sh install-buildkit
sudo -iu "{{.User}}" "XDG_RUNTIME_DIR=/run/user/{{.UID}}" containerd-rootless-setuptool.sh install-stargz
else
echo "TODO: install containerd for aarch64 (https://github.com/containerd/nerdctl/issues/191)"
fi
owner: root:root
path: /var/lib/cloud/scripts/per-boot/20-install-containerd.boot.sh
permissions: '0755'
+37
View File
@@ -0,0 +1,37 @@
package api
import (
"net"
"strconv"
"time"
)
// ErrorJSON is returned with "application/json" content type and non-2XX status code
type ErrorJSON struct {
Message string `json:"message"`
}
type IPPort struct {
IP net.IP `json:"ip"`
Port int `json:"port"`
}
func (x *IPPort) String() string {
return net.JoinHostPort(x.IP.String(), strconv.Itoa(x.Port))
}
type Info struct {
// LocalPorts contain 127.0.0.1 and 0.0.0.0.
// LocalPorts do NOT contain addresses such as 127.0.0.53 and 192.168.5.15.
//
// In future, LocalPorts will contain IPv6 addresses (::1 and ::) as well.
LocalPorts []IPPort `json:"localPorts"`
}
type Event struct {
Time time.Time `json:"time,omitempty"`
// The first event contains the full ports as LocalPortsAdded
LocalPortsAdded []IPPort `json:"localPortsAdded,omitempty"`
LocalPortsRemoved []IPPort `json:"localPortsRemoved,omitempty"`
Errors []string `json:"errors,omitempty"`
}
+162
View File
@@ -0,0 +1,162 @@
package client
// Forked from https://github.com/rootless-containers/rootlesskit/blob/v0.14.2/pkg/api/client/client.go
// Apache License 2.0
import (
"context"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"net"
"net/http"
"os"
"github.com/AkihiroSuda/lima/pkg/guestagent/api"
"github.com/pkg/errors"
)
type GuestAgentClient interface {
HTTPClient() *http.Client
Info(context.Context) (*api.Info, error)
Events(context.Context, func(api.Event)) error
}
// NewGuestAgentClient creates a client.
// socketPath is a path to the UNIX socket, without unix:// prefix.
func NewGuestAgentClient(socketPath string) (GuestAgentClient, error) {
if _, err := os.Stat(socketPath); err != nil {
return nil, err
}
hc := &http.Client{
Transport: &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
var d net.Dialer
return d.DialContext(ctx, "unix", socketPath)
},
},
}
return NewGuestAgentClientWithHTTPClient(hc), nil
}
func NewGuestAgentClientWithHTTPClient(hc *http.Client) GuestAgentClient {
return &client{
Client: hc,
version: "v1",
dummyHost: "lima-guestagent",
}
}
type client struct {
*http.Client
// version is always "v1"
// TODO(AkihiroSuda): negotiate the version
version string
dummyHost string
}
func (c *client) HTTPClient() *http.Client {
return c.Client
}
func (c *client) Info(ctx context.Context) (*api.Info, error) {
u := fmt.Sprintf("http://%s/%s/info", c.dummyHost, c.version)
req, err := http.NewRequest("GET", u, nil)
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
resp, err := c.HTTPClient().Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if err := successful(resp); err != nil {
return nil, err
}
var info api.Info
dec := json.NewDecoder(resp.Body)
if err := dec.Decode(&info); err != nil {
return nil, err
}
return &info, nil
}
func (c *client) Events(ctx context.Context, onEvent func(api.Event)) error {
u := fmt.Sprintf("http://%s/%s/events", c.dummyHost, c.version)
req, err := http.NewRequest("GET", u, nil)
if err != nil {
return err
}
req = req.WithContext(ctx)
resp, err := c.HTTPClient().Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if err := successful(resp); err != nil {
return err
}
var ev api.Event
dec := json.NewDecoder(resp.Body)
for {
if err := dec.Decode(&ev); err != nil {
return err
}
onEvent(ev)
}
}
func readAtMost(r io.Reader, maxBytes int) ([]byte, error) {
lr := &io.LimitedReader{
R: r,
N: int64(maxBytes),
}
b, err := ioutil.ReadAll(lr)
if err != nil {
return b, err
}
if lr.N == 0 {
return b, errors.Errorf("expected at most %d bytes, got more", maxBytes)
}
return b, nil
}
// HTTPStatusErrorBodyMaxLength specifies the maximum length of HTTPStatusError.Body
const HTTPStatusErrorBodyMaxLength = 64 * 1024
// HTTPStatusError is created from non-2XX HTTP response
type HTTPStatusError struct {
// StatusCode is non-2XX status code
StatusCode int
// Body is at most HTTPStatusErrorBodyMaxLength
Body string
}
// Error implements error.
// If e.Body is a marshalled string of api.ErrorJSON, Error returns ErrorJSON.Message .
// Otherwise Error returns a human-readable string that contains e.StatusCode and e.Body.
func (e *HTTPStatusError) Error() string {
if e.Body != "" && len(e.Body) < HTTPStatusErrorBodyMaxLength {
var ej api.ErrorJSON
if json.Unmarshal([]byte(e.Body), &ej) == nil {
return ej.Message
}
}
return fmt.Sprintf("unexpected HTTP status %s, body=%q", http.StatusText(e.StatusCode), e.Body)
}
func successful(resp *http.Response) error {
if resp == nil {
return errors.New("nil response")
}
if resp.StatusCode/100 != 2 {
b, _ := readAtMost(resp.Body, HTTPStatusErrorBodyMaxLength)
return &HTTPStatusError{
StatusCode: resp.StatusCode,
Body: string(b),
}
}
return nil
}
+81
View File
@@ -0,0 +1,81 @@
package server
import (
"context"
"encoding/json"
"net/http"
"github.com/AkihiroSuda/lima/pkg/guestagent"
"github.com/AkihiroSuda/lima/pkg/guestagent/api"
"github.com/gorilla/mux"
"github.com/sirupsen/logrus"
)
type Backend struct {
Agent guestagent.Agent
}
func (b *Backend) onError(w http.ResponseWriter, r *http.Request, err error, ec int) {
w.WriteHeader(ec)
w.Header().Set("Content-Type", "application/json")
// it is safe to return the err to the client, because the client is reliable
e := api.ErrorJSON{
Message: err.Error(),
}
_ = json.NewEncoder(w).Encode(e)
}
// GetInfo is the handler for GET /v{N}/info
func (b *Backend) GetInfo(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ctx, cancel := context.WithCancel(ctx)
defer cancel()
info, err := b.Agent.Info(ctx)
if err != nil {
b.onError(w, r, err, http.StatusInternalServerError)
return
}
m, err := json.Marshal(info)
if err != nil {
b.onError(w, r, err, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(m)
}
// GetEvents is the handler for GET /v{N}/events.
func (b *Backend) GetEvents(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ctx, cancel := context.WithCancel(ctx)
defer cancel()
flusher, ok := w.(http.Flusher)
if !ok {
panic("http.ResponseWriter has to implement http.Flusher")
}
w.Header().Set("Content-Type", "application/x-ndjson")
w.WriteHeader(http.StatusOK)
flusher.Flush()
ch := make(chan api.Event)
go b.Agent.Events(ctx, ch)
enc := json.NewEncoder(w)
for ev := range ch {
if err := enc.Encode(ev); err != nil {
logrus.Warn(err)
return
}
flusher.Flush()
}
}
func AddRoutes(r *mux.Router, b *Backend) {
v1 := r.PathPrefix("/v1").Subrouter()
v1.Path("/info").Methods("GET").HandlerFunc(b.GetInfo)
v1.Path("/events").Methods("GET").HandlerFunc(b.GetEvents)
}
+13
View File
@@ -0,0 +1,13 @@
package guestagent
import (
"context"
"github.com/AkihiroSuda/lima/pkg/guestagent/api"
)
type Agent interface {
Info(ctx context.Context) (*api.Info, error)
Events(ctx context.Context, ch chan api.Event)
LocalPorts(ctx context.Context) ([]api.IPPort, error)
}
+151
View File
@@ -0,0 +1,151 @@
package guestagent
import (
"context"
"encoding/binary"
"errors"
"net"
"reflect"
"time"
"github.com/AkihiroSuda/lima/pkg/guestagent/api"
"github.com/AkihiroSuda/lima/pkg/guestagent/procnettcp"
"github.com/sirupsen/logrus"
"github.com/yalue/native_endian"
)
func New(newTicker func() (<-chan time.Time, func())) Agent {
return &agent{
newTicker: newTicker,
}
}
type agent struct {
// Ticker is like time.Ticker.
// We can't use inotify for /proc/net/tcp, so we need this ticker to
// reload /proc/net/tcp.
newTicker func() (<-chan time.Time, func())
}
type eventState struct {
ports []api.IPPort
}
func comparePorts(old, neww []api.IPPort) (added, removed []api.IPPort) {
mRaw := make(map[string]api.IPPort, len(old))
mStillExist := make(map[string]bool, len(old))
for _, f := range old {
k := f.String()
mRaw[k] = f
mStillExist[k] = false
}
for _, f := range neww {
k := f.String()
if _, ok := mRaw[k]; !ok {
added = append(added, f)
}
mStillExist[k] = true
}
for k, stillExist := range mStillExist {
if !stillExist {
if x, ok := mRaw[k]; ok {
removed = append(removed, x)
}
}
}
return
}
func (a *agent) collectEvent(ctx context.Context, st eventState) (api.Event, eventState) {
var (
ev api.Event
err error
)
newSt := st
newSt.ports, err = a.LocalPorts(ctx)
if err != nil {
ev.Errors = append(ev.Errors, err.Error())
ev.Time = time.Now()
return ev, newSt
}
ev.LocalPortsAdded, ev.LocalPortsRemoved = comparePorts(st.ports, newSt.ports)
ev.Time = time.Now()
return ev, newSt
}
func isEventEmpty(ev api.Event) bool {
var empty api.Event
// ignore ev.Time
copied := ev
copied.Time = time.Time{}
return reflect.DeepEqual(empty, copied)
}
func (a *agent) Events(ctx context.Context, ch chan api.Event) {
defer close(ch)
tickerCh, tickerClose := a.newTicker()
defer tickerClose()
var st eventState
for {
var ev api.Event
ev, st = a.collectEvent(ctx, st)
if !isEventEmpty(ev) {
ch <- ev
}
select {
case <-ctx.Done():
return
case _, ok := <-tickerCh:
if !ok {
return
}
logrus.Debug("tick!")
}
}
}
func (a *agent) LocalPorts(ctx context.Context) ([]api.IPPort, error) {
if native_endian.NativeEndian() == binary.BigEndian {
return nil, errors.New("big endian architecture is unsupported, because I don't know how /proc/net/tcp looks like on big endian hosts")
}
var res []api.IPPort
tcpParsed, err := procnettcp.ParseFiles()
if err != nil {
return res, err
}
ipv4Loopback1 := net.ParseIP("127.0.0.1")
for _, f := range tcpParsed {
switch f.Kind {
case procnettcp.TCP, procnettcp.TCP6:
default:
continue
}
isListen := f.State == procnettcp.TCPListen
// We do NOT use net.IP.IsLoopback() here, because we want to exclude addresses like 127.0.0.53 here.
isLocal := f.IP.Equal(net.IPv4zero) || f.IP.Equal(net.IPv6zero) ||
f.IP.Equal(ipv4Loopback1) || f.IP.Equal(net.IPv6loopback)
if isListen && isLocal {
res = append(res,
api.IPPort{
IP: f.IP,
Port: int(f.Port),
})
}
}
return res, nil
}
func (a *agent) Info(ctx context.Context) (*api.Info, error) {
var (
info api.Info
err error
)
info.LocalPorts, err = a.LocalPorts(ctx)
if err != nil {
return nil, err
}
return &info, nil
}
+138
View File
@@ -0,0 +1,138 @@
package procnettcp
import (
"bufio"
"encoding/hex"
"io"
"net"
"strconv"
"strings"
"github.com/pkg/errors"
)
type Kind = string
const (
TCP Kind = "tcp"
TCP6 Kind = "tcp6"
// TODO: "udp", "udp6", "udplite", "udplite6"
)
type State = int
const (
TCPEstablished State = 0x1
TCPListen State = 0xA
)
type Entry struct {
Kind Kind `json:"kind"`
IP net.IP `json:"ip"`
Port uint16 `json:"port"`
State State `json:"state"`
}
func Parse(r io.Reader, kind Kind) ([]Entry, error) {
switch kind {
case TCP, TCP6:
default:
return nil, errors.Errorf("unexpected kind %q", kind)
}
var entries []Entry
sc := bufio.NewScanner(r)
// As of kernel 5.11, ["local_address"] = 1
fieldNames := make(map[string]int)
for i := 0; sc.Scan(); i++ {
line := strings.TrimSpace(sc.Text())
if line == "" {
continue
}
fields := strings.Fields(line)
switch i {
case 0:
for j := 0; j < len(fields); j++ {
fieldNames[fields[j]] = j
}
if _, ok := fieldNames["local_address"]; !ok {
return nil, errors.Errorf("field \"local_address\" not found")
}
if _, ok := fieldNames["st"]; !ok {
return nil, errors.Errorf("field \"st\" not found")
}
default:
// localAddress is like "0100007F:053A"
localAddress := fields[fieldNames["local_address"]]
ip, port, err := ParseAddress(localAddress)
if err != nil {
return entries, err
}
stStr := fields[fieldNames["st"]]
st, err := strconv.ParseUint(stStr, 16, 8)
if err != nil {
return entries, err
}
ent := Entry{
Kind: kind,
IP: ip,
Port: port,
State: int(st),
}
entries = append(entries, ent)
}
}
if err := sc.Err(); err != nil {
return entries, err
}
return entries, nil
}
// ParseAddress parses a string, e.g.,
// "0100007F:0050" (127.0.0.1:80)
// "000080FE00000000FF57A6705DC771FE:0050" ([fe80::70a6:57ff:fe71:c75d]:80)
// "00000000000000000000000000000000:0050" (0.0.0.0:80)
//
// See https://serverfault.com/questions/592574/why-does-proc-net-tcp6-represents-1-as-1000
//
// ParseAddress is expected to be used for /proc/net/{tcp,tcp6} entries on
// little endian machines.
// Not sure how those entries look like on big endian machines.
func ParseAddress(s string) (net.IP, uint16, error) {
split := strings.SplitN(s, ":", 2)
if len(split) != 2 {
return nil, 0, errors.Errorf("unparsable address %q", s)
}
switch l := len(split[0]); l {
case 8, 32:
default:
return nil, 0, errors.Errorf("unparsable address %q, expected length of %q to be 8 or 32, got %d",
s, len(split[0]), l)
}
ipBytes := make([]byte, len(split[0])/2) // 4 bytes (8 chars) or 16 bytes (32 chars)
for i := 0; i < len(split[0])/8; i++ {
quartet := split[0][8*i : 8*(i+1)]
quartetLE, err := hex.DecodeString(quartet) // surprisingly little endian, per 4 bytes
if err != nil {
return nil, 0, errors.Wrapf(err, "unparsable address %q: unparsable quartet %q", s, quartet)
}
for j := 0; j < len(quartetLE); j++ {
ipBytes[4*i+len(quartetLE)-1-j] = quartetLE[j]
}
}
ip := net.IP(ipBytes)
port64, err := strconv.ParseUint(split[1], 16, 16)
if err != nil {
return nil, 0, errors.Errorf("unparsable address %q: unparsable port %q", s, split[1])
}
port := uint16(port64)
return ip, port, nil
}
@@ -0,0 +1,32 @@
package procnettcp
import (
"os"
"github.com/pkg/errors"
)
// ParseFiles parses /proc/net/{tcp, tcp6}
func ParseFiles() ([]Entry, error) {
var res []Entry
files := map[string]Kind{
"/proc/net/tcp": TCP,
"/proc/net/tcp6": TCP6,
}
for file, kind := range files {
r, err := os.Open(file)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
continue
}
return res, err
}
defer r.Close()
parsed, err := Parse(r, kind)
if err != nil {
return res, err
}
res = append(res, parsed...)
}
return res, nil
}
@@ -0,0 +1,58 @@
package procnettcp
import (
"net"
"strings"
"testing"
"gotest.tools/v3/assert"
)
func TestParseTCP(t *testing.T) {
procNetTCP := ` sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
0: 0100007F:8AEF 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 28152 1 0000000000000000 100 0 0 10 0
1: 0103000A:0035 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 31474 1 0000000000000000 100 0 0 10 5
2: 3500007F:0035 00000000:0000 0A 00000000:00000000 00:00000000 00000000 102 0 30955 1 0000000000000000 100 0 0 10 0
3: 00000000:0016 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 32910 1 0000000000000000 100 0 0 10 0
4: 0100007F:053A 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 31430 1 0000000000000000 100 0 0 10 0
5: 0B3CA8C0:0016 690AA8C0:F705 01 00000000:00000000 02:00028D8B 00000000 0 0 32989 4 0000000000000000 20 4 31 10 19
`
entries, err := Parse(strings.NewReader(procNetTCP), TCP)
assert.NilError(t, err)
t.Log(entries)
assert.Check(t, net.ParseIP("127.0.0.1").Equal(entries[0].IP))
assert.Equal(t, uint16(35567), entries[0].Port)
assert.Equal(t, TCPListen, entries[0].State)
assert.Check(t, net.ParseIP("192.168.60.11").Equal(entries[5].IP))
assert.Equal(t, uint16(22), entries[5].Port)
assert.Equal(t, TCPEstablished, entries[5].State)
}
func TestParseTCP6(t *testing.T) {
procNetTCP := ` sl local_address remote_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
0: 000080FE00000000FF57A6705DC771FE:0050 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 850222 1 0000000000000000 100 0 0 10 0`
entries, err := Parse(strings.NewReader(procNetTCP), TCP6)
assert.NilError(t, err)
t.Log(entries)
assert.Check(t, net.ParseIP("fe80::70a6:57ff:fe71:c75d").Equal(entries[0].IP))
assert.Equal(t, uint16(80), entries[0].Port)
assert.Equal(t, TCPListen, entries[0].State)
}
func TestParseTCP6Zero(t *testing.T) {
procNetTCP := ` sl local_address remote_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
0: 00000000000000000000000000000000:0016 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 33825 1 0000000000000000 100 0 0 10 0
1: 00000000000000000000000000000000:006F 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 26772 1 0000000000000000 100 0 0 10 0
2: 00000000000000000000000000000000:0050 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 1210901 1 0000000000000000 100 0 0 10 0
`
entries, err := Parse(strings.NewReader(procNetTCP), TCP6)
assert.NilError(t, err)
t.Log(entries)
assert.Check(t, net.IPv6zero.Equal(entries[0].IP))
assert.Equal(t, uint16(22), entries[0].Port)
assert.Equal(t, TCPListen, entries[0].State)
}
+185
View File
@@ -0,0 +1,185 @@
package hostagent
import (
"context"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strconv"
"time"
"github.com/AkihiroSuda/lima/pkg/guestagent/api"
"github.com/AkihiroSuda/lima/pkg/guestagent/api/client"
"github.com/AkihiroSuda/lima/pkg/limayaml"
"github.com/AkihiroSuda/lima/pkg/sshutil"
"github.com/AkihiroSuda/sshocker/pkg/ssh"
"github.com/hashicorp/go-multierror"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
)
type HostAgent struct {
y *limayaml.LimaYAML
instDir string
sshConfig *ssh.SSHConfig
portForwarder *portForwarder
onClose []func() error // LIFO
}
func New(y *limayaml.LimaYAML, instDir string) (*HostAgent, error) {
sshArgs, err := sshutil.SSHArgs(instDir)
if err != nil {
return nil, err
}
sshConfig := &ssh.SSHConfig{
AdditionalArgs: sshArgs,
}
a := &HostAgent{
y: y,
instDir: instDir,
sshConfig: sshConfig,
portForwarder: newPortForwarder(sshConfig, y.SSH.LocalPort),
}
return a, nil
}
func (a *HostAgent) Run(ctx context.Context) error {
a.onClose = append(a.onClose, func() error {
logrus.Debugf("shutting down the SSH master")
if exitMasterErr := ssh.ExitMaster("127.0.0.1", a.y.SSH.LocalPort, a.sshConfig); exitMasterErr != nil {
logrus.WithError(exitMasterErr).Warn("failed to exit SSH master")
}
return nil
})
var mErr error
if err := a.waitForRequirements(ctx, "essential", essentialRequirements); err != nil {
mErr = multierror.Append(mErr, err)
}
mounts, err := a.setupMounts(ctx)
if err != nil {
mErr = multierror.Append(mErr, err)
}
a.onClose = append(a.onClose, func() error {
var unmountMErr error
for _, m := range mounts {
if unmountErr := m.close(); unmountErr != nil {
unmountMErr = multierror.Append(unmountMErr, unmountErr)
}
}
return unmountMErr
})
go a.watchGuestAgentEvents(ctx)
if err := a.waitForRequirements(ctx, "optional", optionalRequirements); err != nil {
mErr = multierror.Append(mErr, err)
}
return mErr
}
func (a *HostAgent) Close() error {
logrus.Infof("Shutting down the host agent")
var mErr error
for i := len(a.onClose) - 1; i >= 0; i-- {
f := a.onClose[i]
if err := f(); err != nil {
mErr = multierror.Append(mErr, err)
}
}
return mErr
}
func (a *HostAgent) watchGuestAgentEvents(ctx context.Context) {
// TODO: use vSock (when QEMU for macOS gets support for vSock)
localUnix := filepath.Join(a.instDir, "ga.sock")
// guest should have same UID as the host (specified in cidata)
remoteUnix := fmt.Sprintf("/run/user/%d/lima-guestagent.sock", os.Getuid())
for {
if !isGuestAgentSocketAccessible(ctx, localUnix) {
if err := os.RemoveAll(localUnix); err != nil {
logrus.WithError(err).Warnf("failed to clean up %q (host) before setting up forwarding", localUnix)
}
logrus.Infof("Forwarding %q (guest) to %q (host)", remoteUnix, localUnix)
if err := forwardSSH(ctx, a.sshConfig, a.y.SSH.LocalPort, localUnix, remoteUnix, false); err != nil {
logrus.WithError(err).Warnf("failed to setting up forward from %q (guest) to %q (host)", remoteUnix, localUnix)
}
}
if err := a.processGuestAgentEvents(ctx, localUnix); err != nil {
logrus.WithError(err).Warn("connection to the guest agent was closed unexpectedly")
}
select {
case <-ctx.Done():
logrus.Infof("Stopping forwarding %q to %q", remoteUnix, localUnix)
verbCancel := true
if err := forwardSSH(ctx, a.sshConfig, a.y.SSH.LocalPort, localUnix, remoteUnix, verbCancel); err != nil {
logrus.WithError(err).Warnf("failed to stop forwarding %q (remote) to %q (local)", remoteUnix, localUnix)
}
if err := os.RemoveAll(localUnix); err != nil {
logrus.WithError(err).Warnf("failed to clean up %q (host) after stopping forwarding", localUnix)
}
return
case <-time.After(10 * time.Second):
}
}
}
func isGuestAgentSocketAccessible(ctx context.Context, localUnix string) bool {
client, err := client.NewGuestAgentClient(localUnix)
if err != nil {
return false
}
_, err = client.Info(ctx)
return err == nil
}
func (a *HostAgent) processGuestAgentEvents(ctx context.Context, localUnix string) error {
client, err := client.NewGuestAgentClient(localUnix)
if err != nil {
return err
}
info, err := client.Info(ctx)
if err != nil {
return err
}
logrus.Debugf("guest agent info: %+v", info)
onEvent := func(ev api.Event) {
logrus.Debugf("guest agent event: %+v", ev)
for _, f := range ev.Errors {
logrus.Warnf("received error from the guest: %q", f)
}
a.portForwarder.OnEvent(ctx, ev)
}
if err := client.Events(ctx, onEvent); err != nil {
return err
}
return io.EOF
}
func forwardSSH(ctx context.Context, sshConfig *ssh.SSHConfig, port int, local, remote string, cancel bool) error {
args := sshConfig.Args()
verb := "forward"
if cancel {
verb = "cancel"
}
args = append(args,
"-T",
"-O", verb,
"-L", local+":"+remote,
"-N",
"-f",
"-p", strconv.Itoa(port),
"127.0.0.1",
"--",
)
cmd := exec.CommandContext(ctx, sshConfig.Binary(), args...)
if out, err := cmd.Output(); err != nil {
return errors.Wrapf(err, "failed to run %v: %q", cmd.Args, string(out))
}
return nil
}
+74
View File
@@ -0,0 +1,74 @@
package hostagent
import (
"context"
"os"
"github.com/AkihiroSuda/lima/pkg/limayaml"
"github.com/AkihiroSuda/lima/pkg/localpathutil"
"github.com/AkihiroSuda/sshocker/pkg/reversesshfs"
"github.com/hashicorp/go-multierror"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
)
type mount struct {
close func() error
}
func (a *HostAgent) setupMounts(ctx context.Context) ([]*mount, error) {
var (
res []*mount
mErr error
)
for _, f := range a.y.Mounts {
m, err := a.setupMount(ctx, f)
if err != nil {
mErr = multierror.Append(mErr, err)
continue
}
res = append(res, m)
}
return res, mErr
}
func (a *HostAgent) setupMount(ctx context.Context, m limayaml.Mount) (*mount, error) {
expanded, err := localpathutil.Expand(m.Location)
if err != nil {
return nil, err
}
if err := os.MkdirAll(expanded, 0755); err != nil {
return nil, err
}
logrus.Infof("Mounting %q", expanded)
rsf := &reversesshfs.ReverseSSHFS{
SSHConfig: a.sshConfig,
LocalPath: expanded,
Host: "127.0.0.1",
Port: a.y.SSH.LocalPort,
RemotePath: expanded,
Readonly: !m.Writable,
}
if err := rsf.Prepare(); err != nil {
return nil, errors.Wrapf(err, "failed to prepare reverse sshfs for %q", expanded)
}
if err := rsf.Start(); err != nil {
logrus.WithError(err).Warnf("failed to mount reverse sshfs for %q, retrying with `-o nonempty`", expanded)
// NOTE: nonempty is not supported for libfuse3: https://github.com/canonical/multipass/issues/1381
rsf.SSHFSAdditionalArgs = []string{"-o", "nonempty"}
if err := rsf.Start(); err != nil {
return nil, errors.Wrapf(err, "failed to mount reverse sshfs for %q", expanded)
}
}
res := &mount{
close: func() error {
logrus.Infof("Unmounting %q", expanded)
if closeErr := rsf.Close(); closeErr != nil {
return errors.Wrapf(err, "failed to unmount reverse sshfs for %q", expanded)
}
return nil
},
}
return res, nil
}
+65
View File
@@ -0,0 +1,65 @@
package hostagent
import (
"context"
"strconv"
"github.com/AkihiroSuda/lima/pkg/guestagent/api"
"github.com/AkihiroSuda/sshocker/pkg/ssh"
"github.com/sirupsen/logrus"
)
type portForwarder struct {
sshConfig *ssh.SSHConfig
sshHostPort int
tcp map[int]struct{} // key: int (NOTE: this might be inconsistent with the actual status of SSH master)
}
const sshGuestPort = 22
func newPortForwarder(sshConfig *ssh.SSHConfig, sshHostPort int) *portForwarder {
return &portForwarder{
sshConfig: sshConfig,
sshHostPort: sshHostPort,
tcp: make(map[int]struct{}),
}
}
func (pf *portForwarder) OnEvent(ctx context.Context, ev api.Event) {
ignore := func(x api.IPPort) bool {
switch x.Port {
case sshGuestPort, pf.sshHostPort:
return true
default:
return false
}
}
for _, f := range ev.LocalPortsRemoved {
if ignore(f) {
continue
}
// pf.tcp might be inconsistent with the actual state of the SSH master,
// so we always attempt to cancel forwarding, even when f.Port is not tracked in pf.tcp.
logrus.Infof("Stopping forwarding TCP port %d", f.Port)
verbCancel := true
if err := forwardSSH(ctx, pf.sshConfig, pf.sshHostPort, "127.0.0.1:"+strconv.Itoa(f.Port), "127.0.0.1:"+strconv.Itoa(f.Port), verbCancel); err != nil {
if _, ok := pf.tcp[f.Port]; ok {
logrus.WithError(err).Warnf("failed to stop forwarding TCP port %d", f.Port)
} else {
logrus.WithError(err).Debugf("failed to stop forwarding TCP port %d (negligible)", f.Port)
}
}
delete(pf.tcp, f.Port)
}
for _, f := range ev.LocalPortsAdded {
if ignore(f) {
continue
}
logrus.Infof("Forwarding TCP port %d", f.Port)
if err := forwardSSH(ctx, pf.sshConfig, pf.sshHostPort, "127.0.0.1:"+strconv.Itoa(f.Port), "127.0.0.1:"+strconv.Itoa(f.Port), false); err != nil {
logrus.WithError(err).Warnf("failed to setting up forward TCP port %d (negligible if already forwarded)", f.Port)
} else {
pf.tcp[f.Port] = struct{}{}
}
}
}
+116
View File
@@ -0,0 +1,116 @@
package hostagent
import (
"context"
"time"
"github.com/AkihiroSuda/sshocker/pkg/ssh"
"github.com/hashicorp/go-multierror"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
)
func (a *HostAgent) waitForRequirements(ctx context.Context, label string, requirements []requirement) error {
const (
retries = 60
sleepDuration = 10 * time.Second
)
var mErr error
for i, req := range requirements {
retryLoop:
for j := 0; j < retries; j++ {
logrus.Infof("Waiting for the %s requirement %d of %d: %q", label, i+1, len(requirements), req.description)
err := a.waitForRequirement(ctx, req)
if err == nil {
logrus.Infof("The %s requirement %d of %d is satisfied", label, i+1, len(requirements))
break retryLoop
}
if j == retries-1 {
mErr = multierror.Append(mErr,
errors.Wrapf(err, "failed to satisfy the %s requirement %d of %d %q: %s",
label, i+1, len(requirements), req.description, req.debugHint))
break retryLoop
}
time.Sleep(10 * time.Second)
}
}
return mErr
}
func (a *HostAgent) waitForRequirement(ctx context.Context, r requirement) error {
logrus.Debugf("executing script %q", r.description)
stdout, stderr, err := ssh.ExecuteScript("127.0.0.1", a.y.SSH.LocalPort, a.sshConfig, r.script, r.description)
logrus.Debugf("stdout=%q, stderr=%q, err=%v", stdout, stderr, err)
if err != nil {
return errors.Wrapf(err, "stdout=%q, stderr=%q", stdout, stderr)
}
return nil
}
type requirement struct {
description string
script string
debugHint string
}
var essentialRequirements = []requirement{
{
description: "ssh",
script: `#!/bin/bash
true
`,
debugHint: `Failed to SSH into the guest.
Make sure that the YAML field "ssh.localPort" is not used by other processes on the host.
If any private key under ~/.ssh is protected with a passphrase, you need to have ssh-agent to be running.
`,
},
{
description: "sshfs binary to be installed",
script: `#!/bin/bash
set -eux -o pipefail
if ! timeout 30s bash -c "until command -v sshfs; do sleep 3; done"; then
echo >&2 "sshfs is not installed yet"
exit 1
fi
`,
debugHint: `The sshfs binary was not installed in the guest.
Make sure that you are using an officially supported image.
Also see "/var/log/cloud-init-output.log" in the guest.
A possible workaround is to run "apt-get install sshfs" in the guest.
`,
},
{
description: "the guest agent to be running",
script: `#!/bin/bash
set -eux -o pipefail
sock="/run/user/$(id -u)/lima-guestagent.sock"
if ! timeout 30s bash -c "until [[ -S \"${sock}\" ]]; do sleep 3; done"; then
echo >&2 "lima-guestagent is not installed yet"
exit 1
fi
`,
debugHint: `The guest agent (/run/user/$UID/lima-guestagent.sock) does not seem running.
Make sure that you are using an officially supported image.
Also see "/var/log/cloud-init-output.log" in the guest.
A possible workaround is to run "lima-guestagent install-systemd" in the guest.
`,
},
}
var optionalRequirements = []requirement{
{
description: "containerd binaries to be installed",
script: `#!/bin/bash
set -eux -o pipefail
if ! timeout 30s bash -c "until command -v nerdctl; do sleep 3; done"; then
echo >&2 "nerdctl is not installed yet"
exit 1
fi
`,
debugHint: `The nerdctl binary was not installed in the guest.
Make sure that you are using an officially supported image.
Also see "/var/log/cloud-init-output.log" in the guest.
`,
},
}
+7
View File
@@ -0,0 +1,7 @@
package iso9660util
type ISO9660 struct {
Name string // "cidata"
FilesFromContent map[string]string // use string, not []byte, for debug printability
FilesFromHostFilePath map[string]string
}
+58
View File
@@ -0,0 +1,58 @@
package iso9660util
import (
"io/ioutil"
"os"
"os/exec"
"strings"
securejoin "github.com/cyphar/filepath-securejoin"
"github.com/pkg/errors"
)
func Write(isoFilePath string, iso *ISO9660) error {
td, err := ioutil.TempDir("", "lima-iso9660util")
if err != nil {
return err
}
defer os.RemoveAll(td)
for k, v := range iso.FilesFromContent {
if strings.ToLower(k) != k {
return errors.Errorf("file name must be lower characters, got %q", k)
}
f, err := securejoin.SecureJoin(td, k)
if err != nil {
return err
}
if err := os.WriteFile(f, []byte(v), 0644); err != nil {
return err
}
}
for k, v := range iso.FilesFromHostFilePath {
if strings.ToLower(k) != k {
return errors.Errorf("file name must be lower characters, got %q", k)
}
f, err := securejoin.SecureJoin(td, k)
if err != nil {
return err
}
cmd := exec.Command("cp", "-a", v, f)
if out, err := cmd.CombinedOutput(); err != nil {
return errors.Wrapf(err, "failed to run %v: %q", cmd.Args, string(out))
}
}
if err := os.RemoveAll(isoFilePath); err != nil {
return err
}
cmd := exec.Command("hdiutil", "makehybrid", "-iso", "-joliet",
"-o", isoFilePath,
"-default-volume-name", iso.Name,
td)
if out, err := cmd.CombinedOutput(); err != nil {
return errors.Wrapf(err, "failed to run %v: %q", cmd.Args, string(out))
}
return nil
}
@@ -0,0 +1,13 @@
//+build !darwin
package iso9660util
import (
"runtime"
"github.com/pkg/errors"
)
func Write(isoFilePath string, iso *ISO9660) error {
return errors.Errorf("unsupported GOOS: %q", runtime.GOOS)
}
+67
View File
@@ -0,0 +1,67 @@
# Arch: "default", "x86_64", "aarch64".
# "default" corresponds to the host architecture.
arch: "default"
# An image must be Ubuntu, and must support cloud-init.
# Debian and Fedora may also work, too, but untested.
# Default: none (must be specified)
images:
# Try to use a local image first.
- location: "~/Downloads/hirsute-server-cloudimg-amd64.img"
arch: "x86_64"
- location: "~/Downloads/hirsute-server-cloudimg-arm64.img"
arch: "aarch64"
# Download the file from the internet when the local file is missing.
- location: "https://cloud-images.ubuntu.com/hirsute/current/hirsute-server-cloudimg-amd64.img"
arch: "x86_64"
- location: "https://cloud-images.ubuntu.com/hirsute/current/hirsute-server-cloudimg-arm64.img"
arch: "aarch64"
# CPUs: if you see performance issues, try limiting cpus to 1.
# Default: 4
cpus: 4
# Memory size
# Default: "4GiB"
memory: "4GiB"
# Disk size
# Default: "100GiB"
disk: "100GiB"
# Expose host directories to the guest
# Default: none
mounts:
- location: "~"
# CAUTION: `writable` SHOULD be false for the home directory.
# Setting `writable` to true is possible, but untested and dangerous.
writable: false
- location: "/tmp/lima"
writable: true
ssh:
# A localhost port of the host. Forwarded to port 22 of the guest.
# Currently, this port number has to be specified manually.
# Default: none
localPort: 60022
firmware:
# Use legacy BIOS instead of UEFI.
# Default: false
legacyBIOS: false
#UNIMPLEMENTED| provision:
#UNIMPLEMENTED| # `system` is executed with the root privilege
#UNIMPLEMENTED| system: |
#UNIMPLEMENTED| #!/bin/bash
#UNIMPLEMENTED| set -eux -o pipefail
#UNIMPLEMENTED| export DEBIAN_FRONTEND=noninteractive
#UNIMPLEMENTED| apt-get install -y vim
#UNIMPLEMENTED| # `user` is executed without the root privilege
#UNIMPLEMENTED| user: |
#UNIMPLEMENTED| #!/bin/bash
#UNIMPLEMENTED| set -eux -o pipefail
#UNIMPLEMENTED| cat <<EOF > ~/.vimrc
#UNIMPLEMENTED| set number
#UNIMPLEMENTED| EOF
+33
View File
@@ -0,0 +1,33 @@
package limayaml
import "runtime"
func FillDefault(y *LimaYAML) {
y.Arch = resolveArch(y.Arch)
for i := range y.Images {
img := &y.Images[i]
if img.Arch == "" {
img.Arch = y.Arch
}
}
if y.CPUs == 0 {
y.CPUs = 4
}
if y.Memory == "" {
y.Memory = "4GiB"
}
if y.Disk == "" {
y.Disk = "100GiB"
}
}
func resolveArch(s string) Arch {
if s == "" || s == "default" {
if runtime.GOOS == "amd64" {
return AARCH64
} else {
return X8664
}
}
return s
}
+39
View File
@@ -0,0 +1,39 @@
package limayaml
type LimaYAML struct {
Arch Arch `yaml:"arch,omitempty"`
Images []Image `yaml:"images"` // REQUIRED
CPUs int `yaml:"cpus,omitempty"`
Memory string `yaml:"memory,omitempty"` // go-units.RAMInBytes
Disk string `yaml:"disk,omitempty"` // go-units.RAMInBytes
Mounts []Mount `yaml:"mounts,omitempty"`
SSH SSH `yaml:"ssh,omitempty"` // REQUIRED (FIXME)
Firmware Firmware `yaml:"firmware,omitempty"`
}
type Arch = string
const (
X8664 Arch = "x86_64"
AARCH64 Arch = "aarch64"
)
type Image struct {
Location string `yaml:"location"` // REQUIRED
Arch string `yaml:"arch,omitempty"`
}
type Mount struct {
Location string `yaml:"location"` // REQUIRED
Writable bool `yaml:"writable,omitempty"`
}
type SSH struct {
LocalPort int `yaml:"localPort,omitempty"` // REQUIRED (FIXME: auto assign)
}
type Firmware struct {
// LegacyBIOS disables UEFI if set.
// LegacyBIOS is ignored for aarch64.
LegacyBIOS bool `yaml:"legacyBIOS,omitempty"`
}
+17
View File
@@ -0,0 +1,17 @@
package limayaml
import (
"gopkg.in/yaml.v2"
)
// Load loads the yaml and fulfills unspecified fields with the default values.
//
// Load does not validate. Use Validate for validation.
func Load(b []byte) (*LimaYAML, error) {
var y LimaYAML
if err := yaml.Unmarshal(b, &y); err != nil {
return nil, err
}
FillDefault(&y)
return &y, nil
}
+8
View File
@@ -0,0 +1,8 @@
package limayaml
import (
_ "embed"
)
//go:embed default.TEMPLATE.yaml
var DefaultTemplate []byte
+13
View File
@@ -0,0 +1,13 @@
package limayaml
import (
"testing"
"gotest.tools/v3/assert"
)
func TestDefaultTemplateYAML(t *testing.T) {
_, err := Load(DefaultTemplate)
assert.NilError(t, err)
// Do not call Validate(y) here, as it fails when `~/lima` is missing
}
+96
View File
@@ -0,0 +1,96 @@
package limayaml
import (
"os"
"path/filepath"
"strings"
"github.com/AkihiroSuda/lima/pkg/localpathutil"
"github.com/docker/go-units"
"github.com/pkg/errors"
)
func Validate(y LimaYAML) error {
FillDefault(&y)
return ValidateRaw(y)
}
func ValidateRaw(y LimaYAML) error {
switch y.Arch {
case X8664, AARCH64:
default:
return errors.Errorf("field `arch` must be %q or %q , got %q", X8664, AARCH64, y.Arch)
}
if len(y.Images) == 0 {
return errors.New("field `images` must be set")
}
for i, f := range y.Images {
if !strings.Contains(f.Location, "://") {
if _, err := localpathutil.Expand(f.Location); err != nil {
return errors.Wrapf(err, "field `images[%d].location` refers to an invalid local file path: %q",
i, f.Location)
}
// f.Location does NOT need to be accessible, so we do NOT check os.Stat(f.Location)
}
switch f.Arch {
case X8664, AARCH64:
default:
return errors.Errorf("field `images.arch` must be %q or %q, got %q", X8664, AARCH64, f.Arch)
}
}
if y.CPUs == 0 {
return errors.New("field `cpus` must be set")
}
if _, err := units.RAMInBytes(y.Memory); err != nil {
return errors.Wrapf(err, "field `memory` has an invalid value")
}
if _, err := units.RAMInBytes(y.Disk); err != nil {
return errors.Wrapf(err, "field `memory` has an invalid value")
}
for i, f := range y.Mounts {
if !filepath.IsAbs(f.Location) && !strings.HasPrefix(f.Location, "~") {
return errors.Errorf("field `mounts[%d].location` must be an absolute path, got %q",
i, f.Location)
}
loc, err := localpathutil.Expand(f.Location)
if err != nil {
return errors.Wrapf(err, "field `mounts[%d].location` refers to an unexpandable path: %q",
i, f.Location)
}
switch loc {
case "/", "/bin", "/dev", "/etc", "/home", "/opt", "/sbin", "/tmp", "/usr", "/var":
return errors.Errorf("field `mounts[%d].location` must not be a system path such as /etc or /usr", i)
}
st, err := os.Stat(loc)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
return errors.Wrapf(err, "field `mounts[%d].location` refers to an inaccessible path: %q",
i, f.Location)
}
} else if !st.IsDir() {
return errors.Wrapf(err, "field `mounts[%d].location` refers to a non-directory path: %q",
i, f.Location)
}
}
switch {
case y.SSH.LocalPort < 0:
return errors.New("field `ssh.localPort` must be > 0")
case y.SSH.LocalPort == 0:
return errors.New("field `ssh.localPort` must be set, e.g, 60022 (FIXME: support automatic port assignment)")
case y.SSH.LocalPort == 22:
return errors.New("field `ssh.localPort` must not be 22")
case y.SSH.LocalPort > 65535:
return errors.New("field `ssh.localPort` must be < 65535")
}
// y.Firmware.LegacyBIOS is ignored for aarch64, but not a fatal error.
return nil
}
+34
View File
@@ -0,0 +1,34 @@
package localpathutil
import (
"os"
"path/filepath"
"strings"
"github.com/pkg/errors"
)
// Expand expands a path like "~", "~/", "~/foo".
// Paths like "~foo/bar" are unsupported.
//
// FIXME: is there an existing library for this?
func Expand(orig string) (string, error) {
s := orig
if s == "" {
return "", errors.New("empty path")
}
homeDir, err := os.UserHomeDir()
if err != nil {
return "", err
}
if strings.HasPrefix(s, "~") {
if s == "~" || strings.HasPrefix(s, "~/") {
s = strings.Replace(s, "~", homeDir, 1)
} else {
// Paths like "~foo/bar" are unsupported.
return "", errors.Errorf("unexpandable path %q", orig)
}
}
return filepath.Abs(s)
}
+185
View File
@@ -0,0 +1,185 @@
package qemu
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"github.com/AkihiroSuda/lima/pkg/limayaml"
"github.com/AkihiroSuda/lima/pkg/localpathutil"
"github.com/docker/go-units"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
)
type Config struct {
Name string
InstanceDir string
LimaYAML *limayaml.LimaYAML
}
func EnsureDisk(cfg Config) error {
diffDisk := filepath.Join(cfg.InstanceDir, "diffdisk")
if _, err := os.Stat(diffDisk); err == nil || !errors.Is(err, os.ErrNotExist) {
// disk is already ensured
return err
}
baseDisk := filepath.Join(cfg.InstanceDir, "basedisk")
if _, err := os.Stat(baseDisk); errors.Is(err, os.ErrNotExist) {
baseDiskTmp := filepath.Join(cfg.InstanceDir, "basedisk.tmp")
if err := os.RemoveAll(baseDiskTmp); err != nil {
return err
}
var ensuredBaseDisk bool
errs := make([]error, len(cfg.LimaYAML.Images))
for i, f := range cfg.LimaYAML.Images {
if f.Arch != cfg.LimaYAML.Arch {
errs[i] = fmt.Errorf("unsupported arch: %q", f.Arch)
continue
}
url := f.Location
if !strings.Contains(url, "://") {
expanded, err := localpathutil.Expand(url)
if err != nil {
return err
}
url = "file://" + expanded
}
cmd := exec.Command("curl", "-fSL", "-o", baseDiskTmp, url)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
logrus.Infof("Attempting to download the image from %q", url)
if err := cmd.Run(); err != nil {
errs[i] = errors.Wrapf(err, "failed to run %v", cmd.Args)
continue
}
if err := os.Rename(baseDiskTmp, baseDisk); err != nil {
return err
}
ensuredBaseDisk = true
break
}
if !ensuredBaseDisk {
return errors.Errorf("failed to download the image, attempted %d candidates, errors=%v",
len(cfg.LimaYAML.Images), errs)
}
}
diskSize, err := units.RAMInBytes(cfg.LimaYAML.Disk)
if err != nil {
return err
}
cmd := exec.Command("qemu-img", "create",
"-f", "qcow2",
"-b", baseDisk,
diffDisk,
strconv.Itoa(int(diskSize)))
if out, err := cmd.CombinedOutput(); err != nil {
return errors.Wrapf(err, "failed to run %v: %q", cmd.Args, string(out))
}
return nil
}
func Cmdline(cfg Config) (string, []string, error) {
y := cfg.LimaYAML
exeBase := "qemu-system-" + y.Arch
exe, err := exec.LookPath(exeBase)
if err != nil {
return "", nil, err
}
var args []string
// Architecture
accel := getAccel(y.Arch)
switch y.Arch {
case limayaml.X8664:
// NOTE: "-cpu host" seems to cause kernel panic
// (MacBookPro 2020, Intel(R) Core(TM) i7-1068NG7 CPU @ 2.30GHz, macOS 11.3, Ubuntu 21.04)
args = append(args, "-cpu", "Haswell-v4")
args = append(args, "-machine", "q35,accel="+accel)
case limayaml.AARCH64:
args = append(args, "-cpu", "cortex-a72")
args = append(args, "-machine", "virt,accel="+accel)
}
// SMP
args = append(args, "-smp",
fmt.Sprintf("%d,sockets=1,cores=%d,threads=1", y.CPUs, y.CPUs))
// Memory
memBytes, err := units.RAMInBytes(y.Memory)
if err != nil {
return "", nil, err
}
args = append(args, "-m", strconv.Itoa(int(memBytes>>20)))
// Firmware
if !y.Firmware.LegacyBIOS {
firmware := filepath.Join(exe,
fmt.Sprintf("../../share/qemu/edk2-%s-code.fd", y.Arch))
if _, err := os.Stat(firmware); err != nil {
return "", nil, err
}
args = append(args, "-drive", fmt.Sprintf("if=pflash,format=raw,readonly,file=%s", firmware))
} else if y.Arch != limayaml.X8664 {
logrus.Warnf("field `firmware.legacyBIOS` is not supported for architecture %q, ignoring", y.Arch)
}
// Root disk
args = append(args, "-drive", fmt.Sprintf("file=%s,if=virtio", filepath.Join(cfg.InstanceDir, "diffdisk")))
args = append(args, "-boot", "c")
// cloud-init
args = append(args, "-cdrom", filepath.Join(cfg.InstanceDir, "cidata.iso"))
// Network
// CIDR is intentionally hardcoded to 192.168.5.0/24, as each of QEMU has its own independent slirp network.
// TODO: enable bridge (with sudo?)
args = append(args, "-net", "nic,model=virtio")
args = append(args, "-net", fmt.Sprintf("user,net=192.168.5.0/24,hostfwd=tcp:127.0.0.1:%d-:22", y.SSH.LocalPort))
// virtio-rng-pci acceralates starting up the OS, according to https://wiki.gentoo.org/wiki/QEMU/Options
args = append(args, "-device", "virtio-rng-pci")
// Misc devices
switch y.Arch {
case limayaml.X8664:
args = append(args, "-device", "virtio-vga")
args = append(args, "-device", "virtio-keyboard-pci")
args = append(args, "-device", "virtio-mouse-pci")
default:
// QEMU does not seem to support virtio-vga for aarch64
args = append(args, "-vga", "none", "-device", "ramfb")
args = append(args, "-device", "usb-ehci")
args = append(args, "-device", "usb-kbd")
args = append(args, "-device", "usb-mouse")
}
args = append(args, "-parallel", "none")
// We also want to enable vsock and virtfs here, but QEMU does not support vsock and virtfs for macOS hosts
// QEMU process
args = append(args, "-name", "lima-"+cfg.Name)
args = append(args, "-pidfile", filepath.Join(cfg.InstanceDir, "qemu-pid"))
return exe, args, nil
}
func getAccel(arch limayaml.Arch) string {
nativeX8664 := arch == limayaml.X8664 && runtime.GOARCH == "amd64"
nativeAARCH64 := arch == limayaml.AARCH64 && runtime.GOARCH == "arm64"
native := nativeX8664 || nativeAARCH64
if native {
switch runtime.GOOS {
case "darwin":
return "hvf"
case "linux":
return "kvm"
}
}
return "tcg"
}
+73
View File
@@ -0,0 +1,73 @@
package sshutil
import (
"os"
"path/filepath"
"runtime"
"strings"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
)
type PubKey struct {
Filename string
Content string
}
// DefaultPubKeys finds ssh public keys from ~/.ssh
func DefaultPubKeys() []PubKey {
homeDir, err := os.UserHomeDir()
if err != nil {
logrus.Warn(err)
return nil
}
files, err := filepath.Glob(filepath.Join(homeDir, ".ssh/*.pub"))
if err != nil {
logrus.Warn(err)
return nil
}
var res []PubKey
for _, f := range files {
if !strings.HasSuffix(f, ".pub") {
panic(errors.Errorf("unexpected ssh public key filename %q", f))
}
entry := PubKey{
Filename: f,
}
if content, err := os.ReadFile(f); err == nil {
entry.Content = strings.TrimSpace(string(content))
} else {
logrus.WithError(err).Warningf("failed to read ssh public key %q", f)
}
res = append(res, entry)
}
return res
}
func SSHArgs(instDir string) ([]string, error) {
controlSock := filepath.Join(instDir, "ssh.sock")
maxSockLen := 104
if runtime.GOOS == "linux" {
maxSockLen = 108
}
if len(controlSock) >= maxSockLen {
// If the instDir was under `~/Library/Application Support/Lima`,
// we could only have 54 chars for the user name and the inst name at maximum:
// len("/Users/32charsXXXXXXXXXXXXXXXXXXXXXXXXX/Library/Application Support/Lima/22charsXXXXXXXXXXXXXXX/ssh.sock") = 104
// So we do not use `~/Library/Application Support`.
return nil, errors.Errorf("socket path %q is too long: > UNIX_PATH_MAX=%d", controlSock, maxSockLen)
}
args := []string{
"-o", "ControlMaster=auto",
"-o", "ControlPath=" + controlSock,
"-o", "ControlPersist=5m",
"-o", "StrictHostKeyChecking=no",
"-o", "NoHostAuthenticationForLocalhost=yes",
"-o", "GSSAPIAuthentication=no",
"-o", "PreferredAuthentications=publickey",
"-o", "Compression=no",
"-o", "BatchMode=yes",
}
return args, nil
}
+11
View File
@@ -0,0 +1,11 @@
package sshutil
import "testing"
func TestDefaultPubKeys(t *testing.T) {
keys := DefaultPubKeys()
t.Logf("found %d public keys", len(keys))
for _, key := range keys {
t.Logf("%s: %q", key.Filename, key.Content)
}
}
+77
View File
@@ -0,0 +1,77 @@
package start
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"github.com/AkihiroSuda/lima/pkg/cidata"
"github.com/AkihiroSuda/lima/pkg/hostagent"
"github.com/AkihiroSuda/lima/pkg/iso9660util"
"github.com/AkihiroSuda/lima/pkg/limayaml"
"github.com/AkihiroSuda/lima/pkg/qemu"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
)
func Start(ctx context.Context, instName, instDir string, y *limayaml.LimaYAML) error {
cidataISO, err := cidata.GenerateISO9660(instName, y)
if err != nil {
return err
}
if err := iso9660util.Write(filepath.Join(instDir, "cidata.iso"), cidataISO); err != nil {
return err
}
qCfg := qemu.Config{
Name: instName,
InstanceDir: instDir,
LimaYAML: y,
}
if err := qemu.EnsureDisk(qCfg); err != nil {
return err
}
qExe, qArgs, err := qemu.Cmdline(qCfg)
if err != nil {
return err
}
qCmd := exec.CommandContext(ctx, qExe, qArgs...)
qCmd.Stdout = os.Stdout
qCmd.Stderr = os.Stderr
logrus.Info("Starting QEMU")
logrus.Debugf("qCmd.Args: %v", qCmd.Args)
if err := qCmd.Start(); err != nil {
return err
}
defer func() {
_ = qCmd.Process.Kill()
}()
sshFixCmd := exec.Command("ssh-keygen",
"-R", fmt.Sprintf("[127.0.0.1]:%d", y.SSH.LocalPort),
"-R", fmt.Sprintf("[localhost]:%d", y.SSH.LocalPort),
)
if out, err := sshFixCmd.CombinedOutput(); err != nil {
return errors.Wrapf(err, "failed to run %v: %q", sshFixCmd.Args, string(out))
}
logrus.Infof("SSH: 127.0.0.1:%d", y.SSH.LocalPort)
hAgent, err := hostagent.New(y, instDir)
if err != nil {
return err
}
defer func() {
if cErr := hAgent.Close(); cErr != nil {
logrus.WithError(cErr).Warn("An error during shutting down the host agent")
}
}()
if err := hAgent.Run(ctx); err == nil {
logrus.Info("READY. Run `lima bash` to open the shell.")
} else {
logrus.WithError(err).Warn("DEGRADED. The VM seems running, but file sharing and port forwarding may not work.")
}
// TODO: daemonize the host process here
return qCmd.Wait()
}
+117
View File
@@ -0,0 +1,117 @@
package store
import (
"os"
"path/filepath"
"strings"
"github.com/AkihiroSuda/lima/pkg/limayaml"
"github.com/containerd/containerd/identifiers"
)
const (
// DotLima is a directory that appears under the home directory.
DotLima = ".lima"
// YAMLFileName appears under an instance dir.
YAMLFileName = "lima.yaml"
)
// LimaDir returns the abstract path of `~/.lima`.
//
// NOTE: We do not use `~/Library/Application Support/Lima` on macOS.
// We use `~/.lima` so that we can have enough space for the length of the socket path,
// which can be only 104 characters on macOS.
func LimaDir() (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", err
}
dir := filepath.Join(homeDir, DotLima)
return dir, nil
}
// Instances returns the names of the instances under LimaDir.
func Instances() ([]string, error) {
limaDir, err := LimaDir()
if err != nil {
return nil, err
}
limaDirList, err := os.ReadDir(limaDir)
if err != nil {
return nil, err
}
var names []string
for _, f := range limaDirList {
if strings.HasPrefix(f.Name(), ".") || strings.HasPrefix(f.Name(), "_") {
continue
}
names = append(names, f.Name())
}
return names, nil
}
// InstanceDir returns the instance dir.
// InstanceDir does not check whether the instance exists
func InstanceDir(name string) (string, error) {
if err := identifiers.Validate(name); err != nil {
return "", err
}
limaDir, err := LimaDir()
if err != nil {
return "", err
}
dir := filepath.Join(limaDir, name)
return dir, nil
}
// InstanceNameFromInstanceDir extracts the instance name
// from the path of the instance directory.
// e.g. "foo" for "/Users/somebody/.lima/foo".
func InstanceNameFromInstanceDir(s string) (string, error) {
base := filepath.Base(s)
return base, identifiers.Validate(base)
}
// LoadYAMLByFilePath loads and validates the yaml.
func LoadYAMLByFilePath(filePath string) (*limayaml.LimaYAML, error) {
if _, err := os.Stat(filePath); err != nil {
return nil, err
}
yContent, err := os.ReadFile(filePath)
if err != nil {
return nil, err
}
y, err := limayaml.Load(yContent)
if err != nil {
return nil, err
}
if err := limayaml.Validate(*y); err != nil {
return nil, err
}
return y, nil
}
// YAMLFilePathByInstanceName returns the yaml file path but does not
// check whether it exists.
func YAMLFilePathByInstanceName(instName string) (string, string, error) {
instDir, err := InstanceDir(instName)
if err != nil {
return "", instDir, err
}
instYAMLPath := filepath.Join(instDir, YAMLFileName)
return instYAMLPath, instDir, nil
}
// LoadYAMLByInstanceName loads and validates the yaml.
// LoadYAMLByInstanceName may return os.ErrNotExist
func LoadYAMLByInstanceName(instName string) (*limayaml.LimaYAML, string, error) {
instYAMLPath, instDir, err := YAMLFilePathByInstanceName(instName)
if err != nil {
return nil, "", err
}
y, err := LoadYAMLByFilePath(instYAMLPath)
if err != nil {
return nil, "", err
}
return y, instDir, nil
}
+18
View File
@@ -0,0 +1,18 @@
package templateutil
import (
"bytes"
"text/template"
)
func Execute(tmpl string, args interface{}) ([]byte, error) {
x, err := template.New("").Parse(tmpl)
if err != nil {
return nil, err
}
var b bytes.Buffer
if err := x.Execute(&b, args); err != nil {
return nil, err
}
return b.Bytes(), nil
}
+6
View File
@@ -0,0 +1,6 @@
package version
var (
// Version is filled on compilation time
Version = "<unknown>"
)