Making a machine from a template: p in a template's action menu, `gvm new` on the command line. deploy.go is the only thing in gvm that brings a machine into being rather than acting on one that exists, and that makes its hard question where rather than whether. A template has no resource pool — vSphere takes it away when a machine is marked as one — so a copy of it has nowhere to run until something says where. That is the one thing it cannot inherit; the folder, the datastore and the hardware it can. So the placement is worked out before anything is asked and the confirmation says it in full: the pool is the one the template's own host belongs to, which on a cluster is the cluster's and leaves the host to DRS the way every other deployment there does. --host pins it, --datastore moves it. The interactive half does not wait. A clone is minutes to the half hour, and a list frozen for that long is a list nobody would start one from. vCenter hangs the task off the template, so the row it was started from shows the progress in its TASK column — which is what live mode was for — and the new machine turns up in the list when it exists, announced on the changed line. `gvm new` does wait: a script that gets its prompt back wants the machine to be there. A template's menu is its own: the one thing that can be done with it at the top, and everything else greyed with "a template" beside it, because vSphere will not start one, snapshot one or reconfigure one. Greyed rather than left out — a menu that changes shape between rows is one nobody learns. Refused before anything is sent: a source that is not a template, a name vSphere would not take, and a name the server already has (which vCenter itself would only refuse several seconds into the clone). No guest customisation — no hostname, no address, no domain join. That is a second machine's worth of vSphere, it is site policy rather than a tool's business, and a half-done version of it would be worse than none. confirmDestructive gains a sibling without the warning, and both now print their fact block from one place. Tested against the simulator end to end: an ordinary machine refused, the same machine marked as a template and deployed from, and what comes out read back off the server — a machine and not another template, in the pool it was given, switched off. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
360 lines
12 KiB
Go
360 lines
12 KiB
Go
// complete.go — shell completion, and the inventory cache behind it.
|
|
//
|
|
// The names one types at gvm are machine names, and they are long, and there
|
|
// are hundreds of them on three servers. Completing them is what makes the
|
|
// non-interactive half usable — `gvm -v v308 snap -l dbse<tab>` — but it cannot
|
|
// be done by asking the vCenters: a shell completion runs on every Tab and has
|
|
// to answer in milliseconds, and three logins take seconds.
|
|
//
|
|
// So it answers out of what gvm last saw. Every sweep of the machine list
|
|
// leaves the names behind in the cache directory, per vCenter and with the time
|
|
// on them, and `--complete-vms` reads that file and nothing else. The cache is
|
|
// therefore always exactly as fresh as the last time somebody looked at the
|
|
// list — which is the right currency for a Tab key, and no currency at all for
|
|
// anything that acts on a machine. Nothing else in gvm reads this file: every
|
|
// command resolves the name it was given against the server itself.
|
|
//
|
|
// `gvm config` says how old it is, because a completion that quietly offers a
|
|
// machine deleted last month is a small mystery worth being able to explain.
|
|
package main
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/integrii/flaggy"
|
|
)
|
|
|
|
// completionFlagNames are the options answered before the flag parser, the same
|
|
// way the update options are (gvm.go). They are what the generated scripts call
|
|
// on every Tab, so they must work on a machine whose configuration is broken —
|
|
// and must never print anything but the candidates. Nobody types them, which is
|
|
// why they are deliberately not in the help.
|
|
var completionFlagNames = []string{"--complete-vms", "--complete-vcenters"}
|
|
|
|
// isCompletionFlag reports whether this argument is one of them, so that the
|
|
// help can be checked against what is actually answered — the same guard the
|
|
// update options have (see the tests).
|
|
func isCompletionFlag(arg string) bool { return contains(completionFlagNames, arg) }
|
|
|
|
// completionFlags answers those options and reports whether it did.
|
|
func completionFlags() bool {
|
|
args := os.Args[1:]
|
|
for i, a := range args {
|
|
if !contains(completionFlagNames, a) {
|
|
continue
|
|
}
|
|
// The word after the option, skipping the "--" the completion scripts
|
|
// put in front of it so that a prefix beginning with a dash cannot be
|
|
// taken for an option of gvm's own.
|
|
rest := ""
|
|
for _, a := range args[i+1:] {
|
|
if a == "--" {
|
|
continue
|
|
}
|
|
rest = a
|
|
break
|
|
}
|
|
switch a {
|
|
case "--complete-vms":
|
|
for _, name := range cachedNames(rest) {
|
|
P(name)
|
|
}
|
|
case "--complete-vcenters":
|
|
for _, name := range cachedVCenters() {
|
|
P(name)
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ------------------------------------------------------------------ the cache
|
|
|
|
// inventoryPath is where the names are kept: the cache directory, beside the
|
|
// update note, and never in the configuration — losing it costs one Tab that
|
|
// offers nothing.
|
|
func inventoryPath() (string, error) {
|
|
dir, err := os.UserCacheDir()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return filepath.Join(dir, selfUpdate.asset, "inventory"), nil
|
|
}
|
|
|
|
// cacheEntry is one machine as the cache remembers it.
|
|
type cacheEntry struct {
|
|
vc string
|
|
when time.Time
|
|
name string
|
|
}
|
|
|
|
// saveInventory writes the machines of the servers that answered.
|
|
//
|
|
// The servers that did not are left exactly as they were: a vCenter that is
|
|
// down, or that this run was not asked about (`-v v308`), must not lose its
|
|
// machines out of the cache — the point of completion is to work when things
|
|
// are not working. Best effort throughout: a cache that cannot be written is
|
|
// not worth a word on the screen, let alone an error.
|
|
func saveInventory(answered []string, rows []vmRow) {
|
|
path, err := inventoryPath()
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
fresh := map[string]bool{}
|
|
for _, name := range answered {
|
|
fresh[name] = true
|
|
}
|
|
|
|
kept := make([]cacheEntry, 0, len(rows))
|
|
for _, e := range loadInventory() {
|
|
if !fresh[e.vc] {
|
|
kept = append(kept, e)
|
|
}
|
|
}
|
|
now := time.Now()
|
|
for _, r := range rows {
|
|
kept = append(kept, cacheEntry{vc: r.vc.Name, when: now, name: r.name})
|
|
}
|
|
|
|
var sb strings.Builder
|
|
for _, e := range kept {
|
|
// One line per machine: the server, when it was read, and the name.
|
|
// Tab separated because a machine name may hold a space and never a tab.
|
|
sb.WriteString(e.vc + "\t" + e.when.Format(time.RFC3339) + "\t" + e.name + "\n")
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
return
|
|
}
|
|
tmp := path + ".new"
|
|
if os.WriteFile(tmp, []byte(sb.String()), 0o600) != nil {
|
|
return
|
|
}
|
|
if os.Rename(tmp, path) != nil {
|
|
os.Remove(tmp)
|
|
}
|
|
}
|
|
|
|
// loadInventory reads it back. A line that does not parse is dropped rather
|
|
// than reported: this file is a convenience and a broken one means one Tab
|
|
// without an answer.
|
|
func loadInventory() []cacheEntry {
|
|
path, err := inventoryPath()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
var out []cacheEntry
|
|
for _, line := range strings.Split(string(data), "\n") {
|
|
f := strings.Split(line, "\t")
|
|
if len(f) != 3 || f[0] == "" || f[2] == "" {
|
|
continue
|
|
}
|
|
when, err := time.Parse(time.RFC3339, f[1])
|
|
if err != nil {
|
|
continue
|
|
}
|
|
out = append(out, cacheEntry{vc: f[0], when: when, name: f[2]})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// cachedNames are the machine names that begin with the prefix, once each and
|
|
// in order. Once each because the same name on two vCenters is one thing to
|
|
// type; in order because a completion list that moves about is a completion
|
|
// list nobody reads.
|
|
func cachedNames(prefix string) []string {
|
|
seen := map[string]bool{}
|
|
var out []string
|
|
for _, e := range loadInventory() {
|
|
if seen[e.name] || !strings.HasPrefix(strings.ToLower(e.name), strings.ToLower(prefix)) {
|
|
continue
|
|
}
|
|
seen[e.name] = true
|
|
out = append(out, e.name)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// cachedVCenters are the servers the cache has seen, which is what `-v`
|
|
// completes against. It comes out of the cache and not out of ~/.gvmrc on
|
|
// purpose: reading the configuration would seal a password standing in the
|
|
// clear in it, and a Tab key must not rewrite a file.
|
|
func cachedVCenters() []string {
|
|
seen := map[string]bool{}
|
|
var out []string
|
|
for _, e := range loadInventory() {
|
|
if seen[e.vc] {
|
|
continue
|
|
}
|
|
seen[e.vc] = true
|
|
out = append(out, e.vc)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// inventoryAge is what `gvm config` says about the cache: how many machines it
|
|
// holds and how long ago each server was read.
|
|
func inventoryAge() string {
|
|
entries := loadInventory()
|
|
if len(entries) == 0 {
|
|
return "-"
|
|
}
|
|
newest := map[string]time.Time{}
|
|
var order []string
|
|
for _, e := range entries {
|
|
if _, seen := newest[e.vc]; !seen {
|
|
order = append(order, e.vc)
|
|
}
|
|
if e.when.After(newest[e.vc]) {
|
|
newest[e.vc] = e.when
|
|
}
|
|
}
|
|
sort.Strings(order)
|
|
|
|
parts := make([]string, 0, len(order))
|
|
for _, vc := range order {
|
|
parts = append(parts, SF("%s %s ago", vc, uptime(time.Since(newest[vc]))))
|
|
}
|
|
return SF("%s from %s", plural(len(entries), "machine"), strings.Join(parts, ", "))
|
|
}
|
|
|
|
// ----------------------------------------------------------------- the scripts
|
|
|
|
// vmFlags are the options that take a machine name and vcFlags the ones that
|
|
// take a vCenter — the only thing about gvm's own command line that the
|
|
// completion has to be told, because it is the only thing flaggy's generated
|
|
// script cannot know: it knows every option there is, and nothing about what
|
|
// any of them means.
|
|
//
|
|
// completionOptionsAreReal (see the tests) checks each one against gvm.go, so
|
|
// an option renamed there cannot leave a completion quietly offering the wrong
|
|
// thing.
|
|
var (
|
|
vmFlags = []string{"-l", "--list", "-n", "--new", "-r", "--remove", "--revert",
|
|
"--removeall", "-o", "--on", "-s", "--shutdown", "-b", "--reboot",
|
|
"--off", "--reset", "--vm", "--from"}
|
|
vcFlags = []string{"-v", "--vcenter", "-p", "--password"}
|
|
)
|
|
|
|
// installedFunction is the completion function flaggy's own script installs,
|
|
// read off the line where it installs it — "compdef _gvm gvm" in zsh,
|
|
// "complete -F _gvm_complete gvm" in bash.
|
|
//
|
|
// Taken from the script rather than written down here, because the name is
|
|
// flaggy's to choose: it builds it out of the parser's name, and a version that
|
|
// built it differently would leave the addendum below calling a function that
|
|
// does not exist, which in a shell is a completion that silently offers
|
|
// nothing.
|
|
func installedFunction(script string) string {
|
|
for _, line := range strings.Split(script, "\n") {
|
|
f := strings.Fields(line)
|
|
switch {
|
|
case len(f) >= 2 && f[0] == "compdef":
|
|
return f[1]
|
|
case len(f) >= 3 && f[0] == "complete" && f[1] == "-F":
|
|
return f[2]
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// completionRequest recognises `gvm completion <shell>` — the subcommand flaggy
|
|
// offers and lists in the help, answered here instead so that the script can
|
|
// carry the machine names as well. A shell this does not know is left to
|
|
// flaggy, whose own message names the ones it can write.
|
|
func completionRequest(args []string) (shell string, ok bool) {
|
|
if len(args) < 2 || !strings.EqualFold(args[0], "completion") {
|
|
return "", false
|
|
}
|
|
return strings.ToLower(args[1]), true
|
|
}
|
|
|
|
// completionScript is flaggy's script for that shell with the names put on top:
|
|
//
|
|
// eval "$(gvm completion zsh)"
|
|
// gvm completion bash > /etc/bash_completion.d/gvm
|
|
//
|
|
// flaggy generates the half that is about gvm's own command line, from the
|
|
// parser itself, so no list here can fall behind the options that exist. This
|
|
// adds the half that is about the estate: after an option that takes a machine,
|
|
// the machines; after -v, the servers. Both go through --complete-vms, which
|
|
// reads the cache and never a vCenter.
|
|
func completionScript(shell, flaggyScript string) (string, bool) {
|
|
names, ok := map[string]func(string) string{"zsh": zshNames, "bash": bashNames}[shell]
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
// Without a function of flaggy's to fall back to there is nothing to add
|
|
// to: half a completion — machine names and no options — would be worse
|
|
// than the whole of flaggy's, which is what this then leaves in place.
|
|
delegate := installedFunction(flaggyScript)
|
|
if delegate == "" {
|
|
return flaggyScript, true
|
|
}
|
|
return flaggyScript + names(delegate), true
|
|
}
|
|
|
|
func zshNames(delegate string) string {
|
|
return strings.Join([]string{
|
|
"",
|
|
"# gvm: the machines and the servers, from what gvm last saw",
|
|
"_gvm_names() {",
|
|
" local prev=${words[CURRENT-1]} cur=${words[CURRENT]}",
|
|
" case $prev in",
|
|
" " + strings.Join(vmFlags, "|") + ")",
|
|
" compadd -- ${(f)\"$(gvm --complete-vms -- ${cur} 2>/dev/null)\"}; return;;",
|
|
" " + strings.Join(vcFlags, "|") + ")",
|
|
" compadd -- ${(f)\"$(gvm --complete-vcenters 2>/dev/null)\"}; return;;",
|
|
" esac",
|
|
" " + delegate + " \"$@\"",
|
|
"}",
|
|
"compdef _gvm_names gvm",
|
|
"",
|
|
}, "\n")
|
|
}
|
|
|
|
func bashNames(delegate string) string {
|
|
return strings.Join([]string{
|
|
"",
|
|
"# gvm: the machines and the servers, from what gvm last saw",
|
|
"_gvm_names() {",
|
|
" local cur=${COMP_WORDS[COMP_CWORD]} prev=${COMP_WORDS[COMP_CWORD-1]}",
|
|
" case $prev in",
|
|
" " + strings.Join(vmFlags, "|") + ")",
|
|
" COMPREPLY=($(compgen -W \"$(gvm --complete-vms -- \"$cur\" 2>/dev/null)\" -- \"$cur\")); return;;",
|
|
" " + strings.Join(vcFlags, "|") + ")",
|
|
" COMPREPLY=($(compgen -W \"$(gvm --complete-vcenters 2>/dev/null)\" -- \"$cur\")); return;;",
|
|
" esac",
|
|
" " + delegate,
|
|
"}",
|
|
"complete -F _gvm_names gvm",
|
|
"",
|
|
}, "\n")
|
|
}
|
|
|
|
// flaggyCompletion is flaggy's own script for that shell, out of the parser as
|
|
// it stands — every subcommand and every option, without a list here to fall
|
|
// behind them. Empty for a shell flaggy does not write, which is the caller's
|
|
// signal to let flaggy answer for itself.
|
|
func flaggyCompletion(shell string) string {
|
|
switch shell {
|
|
case "zsh":
|
|
return flaggy.GenerateZshCompletion(flaggy.DefaultParser)
|
|
case "bash":
|
|
return flaggy.GenerateBashCompletion(flaggy.DefaultParser)
|
|
}
|
|
return ""
|
|
}
|