diff --git a/README.md b/README.md index 43178b3..899bf47 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # pve-go -PVE-Go is a go library for the Porxmox VE API. +PVE-Go is a go library for the Proxmox VE API. The design is based on the Hetzner Clound API implementation: https://github.com/hetznercloud/hcloud-go diff --git a/client.go b/client.go index 9c9710e..21e863e 100644 --- a/client.go +++ b/client.go @@ -46,10 +46,11 @@ type Client struct { debugWriter io.Writer insecure bool - Server ServerClient - Task TaskClient - Pool PoolClient - Node NodeClient + Server ServerClient + Task TaskClient + Pool PoolClient + Node NodeClient + Snippet SnippetClient } type session struct { @@ -133,6 +134,7 @@ func NewClient(options ...ClientOption) *Client { client.Task = TaskClient{client: client} client.Pool = PoolClient{client: client} client.Node = NodeClient{client: client} + client.Snippet = SnippetClient{client: client} return client } diff --git a/server.go b/server.go index fb42968..beedb94 100644 --- a/server.go +++ b/server.go @@ -15,44 +15,56 @@ var ErrServerNotFound = errors.New("server not found") type ServerList []*Server type Server struct { - ID string `json:"vmid"` - Name string `json:"name"` - Node string - Pool string - Status ServerStatus `json:"status"` - Resources Resources - NetDevices NetworkDevices - IPConfig IPConfigs - Nameserver Nameserver - SearchDomain string - UserData *UserData + ID string `json:"vmid"` + Name string `json:"name"` + Node string + Pool string + Status ServerStatus `json:"status"` + Resources Resources + NetDevices NetworkDevices + IPConfig IPConfigs + Nameserver Nameserver + SearchDomain string + UserData *UserData + UserDataStorage string + cicustom bool } -func (s *Server) body() (httpbody, error) { - body := httpbody{ +func (s *Server) UserDataSnippetName() string { + return fmt.Sprintf("%s_userdata", s.ID) +} + +func (s *Server) UserDataSnippet(storage string) string { + return fmt.Sprintf("user=%s", SnippetVolume(storage, s.UserDataSnippetName())) +} + +func (s *Server) body() (b httpbody) { + b = httpbody{ "memory": strconv.FormatInt(s.Resources.Memory, 10), "cores": strconv.FormatInt(s.Resources.Cores, 10), "name": s.Name, "nameserver": s.Nameserver.String(), "searchdomain": s.SearchDomain, } - userdata, err := s.UserData.String() - body["ciuserdata"] = userdata + if s.UserData != nil || s.cicustom { + b["cicustom"] = s.UserDataSnippet(s.UserDataStorage) + } + // TODO userdata, err := s.UserData.String() for i, n := range s.NetDevices { if n == nil { - body["net"+strconv.Itoa(i)] = "" + b["net"+strconv.Itoa(i)] = "" continue } - body["net"+strconv.Itoa(i)] = n.String() + b["net"+strconv.Itoa(i)] = n.String() } for i, ip := range s.IPConfig { if ip == nil { - body["ipconfig"+strconv.Itoa(i)] = "" + b["ipconfig"+strconv.Itoa(i)] = "" continue } - body["ipconfig"+strconv.Itoa(i)] = ip.String() + b["ipconfig"+strconv.Itoa(i)] = ip.String() } - return body, err + return } func (s *Server) InstanceID() string { @@ -94,10 +106,15 @@ func (sc serverConfig) Server(node, id string) (s *Server, err error) { if v, ok := sc[k].(string); ok { s.Name = v } - case "ciuserdata": - if v, ok := sc[k].(string); ok { - s.UserData, err = parseUserData(v) + case "cicustom": + if _, ok := sc[k].(string); ok { + s.cicustom = true } + // TODO + // case "ciuserdata": + // if v, ok := sc[k].(string); ok { + // s.UserData, err = parseUserData(v) + // } case "memory": if v, ok := sc[k].(float64); ok { s.Resources.Memory = int64(v) @@ -349,15 +366,9 @@ func (c *ServerClient) GetStatus(ctx context.Context, s *Server, lock *string) ( return } -func diffBody(cur, n *Server) (b httpbody, err error) { - b, err = n.body() - if err != nil { - return - } - curbody, err := cur.body() - if err != nil { - return - } +func diffBody(cur, n *Server) (b httpbody) { + b = n.body() + curbody := cur.body() for k, v := range b { if curbody[k] == v { delete(b, k) @@ -388,31 +399,40 @@ func (c *ServerClient) resizeDisk(ctx context.Context, s *Server) (err error) { return } -func (c *ServerClient) Update(ctx context.Context, s *Server) (err error) { +func (c *ServerClient) Update(ctx context.Context, s *Server) (t *Task, err error) { cur, _, err := c.getServer(ctx, s.Node, s.ID) if err != nil { return } - body, err := diffBody(cur, s) - if err != nil { - return + body := diffBody(cur, s) + var e []string + if s.UserData != nil { + b, userdata := s.UserData.Bytes() + if userdata == nil { + t, userdata = c.client.Snippet.Create(ctx, s.Node, s.UserDataStorage, s.UserDataSnippetName(), b) + } + if userdata != nil { + e = append(e, fmt.Sprintf("userdata: %s", userdata)) + } } - var ( - cfg error - resize error - ) if len(body) > 0 { - cfg = c.updateConfig(ctx, s, body) + cfg := c.updateConfig(ctx, s, body) + if cfg != nil { + e = append(e, fmt.Sprintf("config: %s", cfg)) + } } if cur.Resources.BootDiskSize != s.Resources.BootDiskSize { - resize = c.resizeDisk(ctx, s) + resize := c.resizeDisk(ctx, s) + if resize != nil { + e = append(e, fmt.Sprintf("resize: %s", resize)) + } } - if cfg != nil && resize != nil { - return fmt.Errorf("config: %s resize: %s", cfg, resize) - } else if cfg != nil { - return fmt.Errorf("config: %s", cfg) - } else if resize != nil { - return fmt.Errorf("resize: %s", resize) + if len(e) > 0 { + err = fmt.Errorf("%s", strings.Join(e, "")) + } + if t == nil { + dummy := DummyTask + t = &dummy } return } diff --git a/snippet.diff b/snippet.diff new file mode 100644 index 0000000..c9e0080 --- /dev/null +++ b/snippet.diff @@ -0,0 +1,36 @@ +diff --git a/PVE/API2/Storage/Status.pm b/PVE/API2/Storage/Status.pm +index 9a5a952..fd7ac5f 100644 +--- a/PVE/API2/Storage/Status.pm ++++ b/PVE/API2/Storage/Status.pm +@@ -417,8 +417,10 @@ __PACKAGE__->register_method ({ + raise_param_exc({ filename => "missing '.tar.gz' or '.tar.xz' extension" }); + } + $path = PVE::Storage::get_vztmpl_dir($cfg, $param->{storage}); +- } else { +- raise_param_exc({ content => "upload content type '$content' not allowed" }); ++ } elsif ($content eq 'snippets') { ++ $path = PVE::Storage::get_snippet_dir($cfg, $param->{storage}); ++ } else { ++ raise_param_exc({ content => "upload content type '$content' not allowed" }); + } + + die "storage '$param->{storage}' does not support '$content' content\n" +diff --git a/PVE/Storage.pm b/PVE/Storage.pm +index eb5e86f..bc0981b 100755 +--- a/PVE/Storage.pm ++++ b/PVE/Storage.pm +@@ -340,6 +340,15 @@ sub get_iso_dir { + return $plugin->get_subdir($scfg, 'iso'); + } + ++sub get_snippet_dir { ++ my ($cfg, $storeid) = @_; ++ ++ my $scfg = storage_config($cfg, $storeid); ++ my $plugin = PVE::Storage::Plugin->lookup($scfg->{type}); ++ ++ return $plugin->get_subdir($scfg, 'snippets'); ++} ++ + sub get_vztmpl_dir { + my ($cfg, $storeid) = @_; diff --git a/snippet.go b/snippet.go new file mode 100644 index 0000000..be30c47 --- /dev/null +++ b/snippet.go @@ -0,0 +1,103 @@ +// Copyright (C) 2020 Marius Schellenberger + +package pve + +import ( + "bytes" + "context" + "errors" + "fmt" + "mime/multipart" + "net/http" + "time" +) + +var ( + ErrNoSnippetNode = errors.New("missing snippet target node") + ErrNoSnippetStorage = errors.New("missing snippet target storage") + ErrNoSnippetName = errors.New("missing snippet name") +) + +type CopyBuffer struct { + *bytes.Buffer +} + +func (c *CopyBuffer) Copy() *bytes.Buffer { + buf := make([]byte, c.Len()) + copy(buf, c.Bytes()) + return bytes.NewBuffer(buf) +} + +type SnippetClient struct { + client *Client +} + +func (c *SnippetClient) Create(ctx context.Context, node, storage, name string, data []byte) (t *Task, err error) { + if node == "" { + err = ErrNoSnippetNode + return + } + if storage == "" { + err = ErrNoSnippetStorage + return + } + if name == "" { + err = ErrNoSnippetName + return + } + var b bytes.Buffer + w := multipart.NewWriter(&b) + err = w.WriteField("content", "snippets") + if err != nil { + return + } + fw, err := w.CreateFormFile("filename", name) + if err != nil { + return + } + if _, err = fw.Write(data); err != nil { + return + } + w.Close() + cb := CopyBuffer{&b} + ct := w.FormDataContentType() + var taskid string + var req *http.Request + var resp *http.Response + for i := 0; i < 3; i++ { + buf := cb.Copy() + req, err = c.client.NewRequest(ctx, "POST", fmt.Sprintf("/nodes/%s/storage/%s/upload", node, storage), buf) + req.Header.Set("Content-Type", ct) + resp, err = c.client.Do(req, &taskid) + if err != nil { + time.Sleep(time.Millisecond * 500) + // TODO log error + continue + } + if resp.StatusCode != http.StatusOK { + time.Sleep(time.Millisecond * 500) + // TODO log error + continue + } + break + } + t = c.client.Task.MustGet(ctx, taskid) + return +} + +func SnippetVolume(storage, name string) string { + return fmt.Sprintf("%s:snippets/%s", storage, name) +} + +func (c *SnippetClient) Delete(ctx context.Context, node, storage, name string) (t *Task, err error) { + //volume := fmt.Sprintf("/%s:snippets/%s", storage, name) + volume := SnippetVolume(storage, name) + req, err := c.client.NewRequest(ctx, "DELETE", fmt.Sprintf("/nodes/%s/storage/%s/content/%s", node, storage, volume), nil) + if err != nil { + return + } + var taskid string + _, err = c.client.Do(req, &taskid) + t = c.client.Task.MustGet(ctx, taskid) + return +} diff --git a/task.go b/task.go index 89bfa14..3f1d00d 100644 --- a/task.go +++ b/task.go @@ -16,8 +16,12 @@ const ( TaskExitStatusOK = "OK" TaskStatusRunning = "running" TaskStatusStopped = "stopped" + + DummyTaskID = "dummy" ) +var DummyTask = Task{ID: DummyTaskID} + type Task struct { ID string `json:"upid"` Type string `json:"type"` @@ -63,6 +67,9 @@ func (c *TaskClient) Get(ctx context.Context, taskid string) (t *Task, err error type OnTaskChange func(t *Task) func (c *TaskClient) Wait(ctx context.Context, t *Task, f OnTaskChange) error { + if t.ID == DummyTaskID { + return nil + } if f != nil && t.Status != "" { f(t) } diff --git a/userdata.go b/userdata.go index 9fad585..1bf2dbb 100644 --- a/userdata.go +++ b/userdata.go @@ -3,40 +3,51 @@ package pve import ( - "encoding/base64" + //"encoding/base64" + //"net/url" "gopkg.in/yaml.v2" - "net/url" ) var cloudConfig = []byte("#cloud-config\n") -func parseUserData(s string) (u *UserData, err error) { - s, err = url.QueryUnescape(s) - if err != nil { - return - } - b, err := base64.StdEncoding.DecodeString(s) - if err != nil { - return - } - return UnmarshalUserData(b) -} +const ( + minBytes = 87 + pad = "########################################################################\n" +) + +//TODO +//func parseUserData(s string) (u *UserData, err error) { +// s, err = url.QueryUnescape(s) +// if err != nil { +// return +// } +// b, err := base64.StdEncoding.DecodeString(s) +// if err != nil { +// return +// } +// return UnmarshalUserData(b) +//} func UnmarshalUserData(b []byte) (u *UserData, err error) { u = new(UserData) err = yaml.Unmarshal(b, u) return } -func MarshalUserData(u *UserData) ([]byte, error) { - if u == nil { - return nil, nil - } - b, err := yaml.Marshal(u) - if err == nil { - b = append(cloudConfig, b...) - } - return b, err -} + +//TODO +//func MarshalUserData(u *UserData) ([]byte, error) { +// if u == nil { +// return nil, nil +// } +// b, err := yaml.Marshal(u) +// if err == nil { +// if len(b)+len(cloudConfig) < minBytes { +// b = append([]byte(pad[len(b):]), b...) +// } +// b = append(cloudConfig, b...) +// } +// return b, err +//} type UserData struct { Hostname string `yaml:"hostname"` @@ -53,12 +64,33 @@ type UserData struct { WriteFiles []File `yaml:"write_files,omitempty"` } +// TODO +// func (u *UserData) String() (s string, err error) { +// b, err := MarshalUserData(u) +// if err != nil { +// return +// } +// s = url.QueryEscape(base64.StdEncoding.EncodeToString(b)) +// return +// } + func (u *UserData) String() (s string, err error) { - b, err := MarshalUserData(u) + b, err := u.Bytes() if err != nil { return } - s = url.QueryEscape(base64.StdEncoding.EncodeToString(b)) + s = string(b) + return +} + +func (u *UserData) Bytes() (b []byte, err error) { + b, err = yaml.Marshal(u) + if err == nil { + if len(b)+len(cloudConfig) < minBytes { + b = append([]byte(pad[len(b):]), b...) + } + b = append(cloudConfig, b...) + } return }