A package manager opens several connections to one repository at once and closes those it does not use. The cache saw three cut handshakes in the same second, counted three refusals, and moved the host to an opaque tunnel, which never consults the store. A client then waited four hours on that tunnel, holding a socket the far end had closed, until its own deadline killed it. Cuts closer than two seconds now count as one incident; a client that really rejects the authority retries seconds later and is banned as before. A closing tunnel is logged with its duration and bytes each way. --- FR --- Un gestionnaire de paquets ouvre plusieurs connexions de front vers un dépôt et ferme celles dont il ne se sert pas. Le cache voyait trois poignées de main coupées dans la même seconde, comptait trois refus, et passait l'hôte en tunnel opaque, qui ne consulte jamais le magasin. Un client attendait alors quatre heures sur ce tunnel, sur un socket que l'autre bout avait refermé, jusqu'à son propre délai. Deux coupures à moins de deux secondes comptent désormais pour un incident ; un client qui rejette vraiment l'autorité réessaie et reste condamné. La fin d'un tunnel est journalisée. Assisted-by: Claude Opus 5
496 lines
16 KiB
Go
496 lines
16 KiB
Go
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
|
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
|
|
// erplibre_go_qemu_cache — miroir de téléchargement pour les VM QEMU locales.
|
|
//
|
|
// L'orchestrateur détourne le 80 et le 443 de ses VM vers cet outil, qui
|
|
// garde ce qui descend et sert la copie à la VM suivante. Deux VM de la même
|
|
// distribution ne tirent donc qu'une fois les mêmes paquets.
|
|
//
|
|
// Ce que l'outil NE fait pas, et qu'il faut savoir : il n'efface rien. Aucune
|
|
// éviction, aucun plafond de disque — « --status » dit ce qu'il occupe, la
|
|
// surveillance est manuelle et le cache vit sur le disque de l'orchestrateur.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"flag"
|
|
"fmt"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const version = "0.2.12"
|
|
|
|
func main() {
|
|
var (
|
|
cacheDir = flag.String("cache-dir", "/var/cache/erplibre_go_qemu_cache",
|
|
T("répertoire des objets gardés"))
|
|
caDir = flag.String("ca-dir", "/var/lib/erplibre_go_qemu_cache",
|
|
T("répertoire de l'autorité de certification"))
|
|
httpPort = flag.Int("http-port", 8898, T("écoute pour le 80 détourné"))
|
|
tlsPort = flag.Int("tls-port", 8899, T("écoute pour le 443 détourné"))
|
|
bridge = flag.String("bridge", "virbr0", T("pont libvirt des VM"))
|
|
subnet = flag.String("subnet", "192.168.122.0/24", T("sous-réseau des VM"))
|
|
logPath = flag.String("access-log", "", T("journal d'accès JSON par ligne"))
|
|
exclude = flag.String("exclude", "",
|
|
T("hôtes à ne jamais déchiffrer, séparés par des virgules"))
|
|
verbose = flag.Bool("verbose", false, T("dire chaque requête"))
|
|
status = flag.Bool("status", false,
|
|
T("dire ce que le cache occupe, puis sortir"))
|
|
dryRun = flag.Bool("dry-run", false,
|
|
T("montrer les gestes privilégiés sans en faire un"))
|
|
initCA = flag.Bool("init-ca", false,
|
|
T("créer l'autorité si elle manque, puis sortir"))
|
|
printNft = flag.Bool("print-nft", false,
|
|
T("écrire les règles nft seules, à passer à « nft -f - »"))
|
|
printIptables = flag.Bool("print-iptables", false,
|
|
T("écrire les commandes iptables seules, une par ligne"))
|
|
bypassFile = flag.String("bypass-file",
|
|
"/etc/erplibre_go_qemu_cache/bypass",
|
|
T("liste des VM soustraites au détournement, une MAC par ligne"))
|
|
bypassAdd = flag.String("bypass-add", "",
|
|
T("soustraire cette adresse MAC au détournement"))
|
|
bypassName = flag.String("bypass-name", "",
|
|
T("nom de la VM, écrit à côté de la MAC ajoutée"))
|
|
bypassDel = flag.String("bypass-del", "",
|
|
T("rendre cette adresse MAC au détournement"))
|
|
gitMirrorDir = flag.String("git-mirror-dir", "",
|
|
T("racine des dépôts git tenus en miroir ; vide, git est"+
|
|
" simplement relayé vers l'amont"))
|
|
gitMirrorFresh = flag.Duration("git-mirror-fresh", 60*time.Second,
|
|
T("délai en deçà duquel un dépôt n'est pas re-interrogé"))
|
|
gitPrefetch = flag.String("git-mirror-prefetch", "",
|
|
T("fichier de dépôts, un par ligne, à tenir en miroir d'avance"))
|
|
gitPrefetchJobs = flag.Int("git-mirror-jobs", 4,
|
|
T("dépôts clonés en parallèle par le pré-remplissage"))
|
|
ageReport = flag.Bool("age-report", false,
|
|
T("dire ce que le cache occupe, groupé par âge du dernier usage"))
|
|
agePar = flag.String("age-par", "semaine",
|
|
T("découpage du relevé par âge : jour, semaine ou mois"))
|
|
purgeTout = flag.Bool("purge", false,
|
|
T("effacer TOUT le cache : objets et dépôts en miroir"))
|
|
purgeAvant = flag.String("purge-older-than", "",
|
|
T("n'effacer que ce qui n'a pas servi depuis ce délai (ex. 30j, 12h)"))
|
|
purgeTaille = flag.String("purge-to-size", "",
|
|
T("effacer le moins récemment servi jusqu'à tenir sous cette taille (ex. 50G)"))
|
|
gitList = flag.Bool("git-mirror-list", false,
|
|
T("dire les dépôts tenus en miroir, du plus lourd au plus léger"))
|
|
gitRemove = flag.String("git-mirror-remove", "",
|
|
T("effacer le miroir de ce dépôt ; il se refera au prochain besoin"))
|
|
bypassList = flag.Bool("bypass-list", false,
|
|
T("dire les exceptions en place, une « MAC nom » par ligne"))
|
|
showVersion = flag.Bool("version", false, T("dire la version, puis sortir"))
|
|
lang = flag.String("lang", "", T("langue des messages : fr ou en ; à défaut, EL_LANG"))
|
|
detient = flag.Bool("detient", false,
|
|
T("lire des lignes « MÉTHODE URL » sur l'entrée standard et dire,"+
|
|
" pour chacune, ce que le magasin tient : une ligne séparée"+
|
|
" par des tabulations « verdict statut stored_at classe"+
|
|
" méthode url », verdict garde (corps 200), statut (statut"+
|
|
" seul, sans corps), absent ou non-cachable. Lecture seule :"+
|
|
" --cache-dir suffit, sans privilège, et l'âge des objets"+
|
|
" n'est pas touché"))
|
|
)
|
|
flag.Parse()
|
|
// Déjà lue dans os.Args avant l'analyse (voir Langue) ; posée ici aussi
|
|
// pour que la valeur retenue soit celle que flag a comprise.
|
|
if *lang != "" {
|
|
definirLangue(*lang)
|
|
}
|
|
|
|
if *showVersion {
|
|
fmt.Printf("erplibre_go_qemu_cache %s\n", version)
|
|
return
|
|
}
|
|
// Traité AVANT tout ce qui lit une configuration : la question ne
|
|
// porte que sur le magasin, et un fichier d'exceptions illisible pour
|
|
// l'appelant ne doit pas l'empêcher d'y répondre.
|
|
if *detient {
|
|
store := &Store{Dir: *cacheDir}
|
|
if err := EcrireDetentions(store, os.Stdin, os.Stdout); err != nil {
|
|
fmt.Fprintf(os.Stderr, T("entrée illisible : %v\n"), err)
|
|
}
|
|
return
|
|
}
|
|
|
|
bypass := BypassFile{Path: *bypassFile}
|
|
// Les exceptions entrent dans les règles dès leur RENDU : le service les
|
|
// repose telles quelles à chaque démarrage, et une exception ne survit
|
|
// donc pas au seul noyau.
|
|
exceptions, err := bypass.Load()
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, T("exceptions illisibles : %v\n"), err)
|
|
os.Exit(1)
|
|
}
|
|
rules := RuleSet{
|
|
Bridge: *bridge, Subnet: *subnet,
|
|
HTTPPort: *httpPort, TLSPort: *tlsPort,
|
|
Bypass: MACs(exceptions),
|
|
}
|
|
store := &Store{Dir: *cacheDir}
|
|
|
|
// Les règles sortent d'ici et de nulle part ailleurs : le service les
|
|
// pose en tubant cette sortie dans nft. Une seconde copie dans un script
|
|
// d'installation dériverait de celle que les tests vérifient.
|
|
if *printNft {
|
|
for _, l := range rules.NftLines() {
|
|
fmt.Println(l)
|
|
}
|
|
return
|
|
}
|
|
if *printIptables {
|
|
for _, l := range rules.IptablesLines() {
|
|
fmt.Println(l)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Les trois gestes de la liste écrivent le fichier et rendent sur la
|
|
// SORTIE le geste à chaud correspondant, à tuber dans « nft -f - ». Ce
|
|
// paquet ne touche pas au pare-feu : c'est l'invariant qui rend les
|
|
// règles vérifiables par un test sans privilège.
|
|
if *bypassAdd != "" {
|
|
mac, err := bypass.Add(*bypassAdd, *bypassName)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, T("exception refusée : %v\n"), err)
|
|
os.Exit(1)
|
|
}
|
|
fmt.Fprintf(os.Stderr, T("exception posée : %s %s\n"), mac, *bypassName)
|
|
fmt.Println(BypassAddElement(mac))
|
|
return
|
|
}
|
|
if *bypassDel != "" {
|
|
mac, avait, err := bypass.Del(*bypassDel)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, T("exception refusée : %v\n"), err)
|
|
os.Exit(1)
|
|
}
|
|
if !avait {
|
|
fmt.Fprintf(os.Stderr, T("aucune exception pour %s\n"), mac)
|
|
}
|
|
// Le geste à chaud est rendu même si le fichier ne l'avait pas :
|
|
// l'ensemble du noyau peut porter ce que le fichier a perdu, et le
|
|
// retrait doit alors pouvoir le rattraper.
|
|
fmt.Println(BypassDelElement(mac))
|
|
return
|
|
}
|
|
if *bypassList {
|
|
for _, e := range exceptions {
|
|
fmt.Printf("%s %s\n", e.MAC, e.Name)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Une seule mémoire des amonts muets, partagée par le relais et le
|
|
// miroir : un hôte coupé l'est pour les deux, et le premier qui le
|
|
// constate en épargne le délai à l'autre.
|
|
muets := NouvelleJoignabilite()
|
|
// Le témoin que la levée d'une coupure touche. Il vit dans le magasin,
|
|
// seul répertoire que le service et la levée connaissent tous deux.
|
|
muets.Sentinelle = filepath.Join(*cacheDir, SentinelleAmonts)
|
|
miroir := &GitMirror{
|
|
Dir: *gitMirrorDir, Frais: *gitMirrorFresh, Muets: muets,
|
|
}
|
|
|
|
if *ageReport {
|
|
gran, err := LireGranularite(*agePar)
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
os.Exit(1)
|
|
}
|
|
printAge(store, miroir, gran)
|
|
return
|
|
}
|
|
if *purgeTout || *purgeAvant != "" {
|
|
avant := time.Now()
|
|
if *purgeAvant != "" {
|
|
d, err := LireDuree(*purgeAvant)
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
os.Exit(1)
|
|
}
|
|
avant = time.Now().Add(-d)
|
|
fmt.Printf(T("efface ce qui n'a pas servi depuis %s (avant %s)\n"),
|
|
*purgeAvant, avant.Format("2006-01-02 15:04"))
|
|
} else {
|
|
fmt.Println(T("efface TOUT le cache"))
|
|
}
|
|
if *dryRun {
|
|
printPurgeABlanc(store, miroir, avant)
|
|
return
|
|
}
|
|
n, oct, err := store.Purger(avant)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, T("purge des objets : %v\n"), err)
|
|
}
|
|
fmt.Printf(T("objets effacés : %d, %s rendus\n"), n, HumanBytes(oct))
|
|
nd, octd, err := miroir.PurgerMiroirs(avant)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, T("purge des miroirs : %v\n"), err)
|
|
}
|
|
fmt.Printf(T("dépôts effacés : %d, %s rendus\n"), nd, HumanBytes(octd))
|
|
return
|
|
}
|
|
if *purgeTaille != "" {
|
|
plafond, err := LireTaille(*purgeTaille)
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
os.Exit(1)
|
|
}
|
|
b, err := PurgerJusqua(store, miroir, plafond, *dryRun)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, T("purge au plafond : %v\n"), err)
|
|
}
|
|
prefixe := ""
|
|
if *dryRun {
|
|
prefixe = T("[à blanc] ")
|
|
}
|
|
fmt.Printf(T("plafond %s : le cache occupe %s\n"), HumanBytes(plafond), HumanBytes(b.Avant))
|
|
fmt.Printf(T("%sobjets : %d, %s ; dépôts : %d, %s ; reste %s\n"), prefixe,
|
|
b.Objets, HumanBytes(b.OctetsObjets), b.Depots, HumanBytes(b.OctetsDepots),
|
|
HumanBytes(b.Apres))
|
|
return
|
|
}
|
|
if *gitList {
|
|
depots := miroir.Depots()
|
|
if len(depots) == 0 {
|
|
fmt.Println(T("aucun dépôt en miroir"))
|
|
return
|
|
}
|
|
var total int64
|
|
for _, d := range depots {
|
|
total += d.Octets
|
|
fmt.Printf("%10s %s %s\n",
|
|
HumanBytes(d.Octets), d.Maj.Format("2006-01-02"), d.Nom)
|
|
}
|
|
fmt.Printf(T("%10s %d dépôts\n"), HumanBytes(total), len(depots))
|
|
return
|
|
}
|
|
if *gitRemove != "" {
|
|
// Le nom suffit : on ne demande pas à l'opérateur de retrouver le
|
|
// chemin d'un répertoire qu'il n'a pas choisi.
|
|
cible := *gitRemove
|
|
if !strings.HasPrefix(cible, miroir.Dir) {
|
|
for _, d := range miroir.Depots() {
|
|
if d.Nom == strings.TrimSuffix(cible, ".git") {
|
|
cible = d.Chemin
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if err := miroir.Retirer(cible); err != nil {
|
|
fmt.Fprintf(os.Stderr, T("effacement : %v\n"), err)
|
|
os.Exit(1)
|
|
}
|
|
fmt.Printf(T("miroir effacé : %s\n"), cible)
|
|
return
|
|
}
|
|
if *gitPrefetch != "" {
|
|
if !miroir.Actif() {
|
|
fmt.Fprintln(os.Stderr,
|
|
T("miroir git éteint : passer --git-mirror-dir"))
|
|
os.Exit(1)
|
|
}
|
|
depots, err := DepotsDuFichier(*gitPrefetch)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, T("liste illisible : %v\n"), err)
|
|
os.Exit(1)
|
|
}
|
|
fmt.Printf(T("%d dépôts à tenir en miroir sous %s\n"),
|
|
len(depots), miroir.Dir)
|
|
reussis, echoues := miroir.Prefetch(
|
|
context.Background(), depots, *gitPrefetchJobs,
|
|
func(l string) { fmt.Println(l) },
|
|
)
|
|
_, octets := miroir.Occupation()
|
|
fmt.Printf(T("%d en miroir, %d en échec, %s occupés\n"),
|
|
reussis, echoues, HumanBytes(octets))
|
|
// Un dépôt mort ne fait pas échouer l'opération : sur une liste de
|
|
// trois cents, il y en a toujours un — privé, déplacé, retiré — et
|
|
// rendre une erreur ferait passer pour ratée une avance qui a pris.
|
|
// Seule une liste dont RIEN n'a été tenu est un échec.
|
|
if reussis == 0 && echoues > 0 {
|
|
os.Exit(1)
|
|
}
|
|
return
|
|
}
|
|
|
|
if *status {
|
|
if err := printStatus(store, *caDir, rules, miroir); err != nil {
|
|
fmt.Fprintf(os.Stderr, T("état illisible : %v\n"), err)
|
|
os.Exit(1)
|
|
}
|
|
return
|
|
}
|
|
|
|
if *dryRun {
|
|
printDryRun(store, *caDir, rules)
|
|
return
|
|
}
|
|
|
|
if *initCA {
|
|
ca, err := LoadOrCreateCA(*caDir)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, T("autorité : %v\n"), err)
|
|
os.Exit(1)
|
|
}
|
|
fmt.Printf(T("autorité : %s\n"), CertPath(*caDir))
|
|
fmt.Printf(T("empreinte : %s\n"), ca.Fingerprint())
|
|
return
|
|
}
|
|
|
|
if *gitMirrorDir != "" && !miroir.Actif() {
|
|
// Le dire plutôt que de laisser croire à un miroir : sans le
|
|
// programme de git, le service marche mais git repart à l'amont à
|
|
// chaque VM, ce qui est exactement ce que le miroir devait éviter.
|
|
log.Printf(
|
|
T("miroir git demandé mais « git-http-backend » est introuvable :"+
|
|
" git sera relayé vers l'amont (%s)"), *gitMirrorDir)
|
|
}
|
|
if err := serve(
|
|
store, *caDir, rules, *logPath, *exclude, *verbose, miroir, muets,
|
|
); err != nil {
|
|
log.Fatalf(T("le cache s'arrête : %v"), err)
|
|
}
|
|
}
|
|
|
|
func serve(
|
|
store *Store, caDir string, rules RuleSet,
|
|
logPath, exclude string, verbose bool, miroir *GitMirror,
|
|
muets *Joignabilite,
|
|
) error {
|
|
if err := os.MkdirAll(store.Dir, 0o755); err != nil {
|
|
return err
|
|
}
|
|
// Un objet à moitié écrit ne vaut rien et occupe : le démarrage est le
|
|
// seul moment où un « .part » n'appartient à aucune écriture vivante.
|
|
if n := store.SweepPartials(); n > 0 {
|
|
log.Printf(T("%d écriture(s) interrompue(s) retirée(s)"), n)
|
|
}
|
|
|
|
ca, err := LoadOrCreateCA(caDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
alog, err := OpenAccessLog(logPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer alog.Close()
|
|
|
|
proxy := NewProxy(store, alog)
|
|
proxy.Git = miroir
|
|
proxy.Verbose = verbose
|
|
proxy.Muets = muets
|
|
// Les ports d'écoute sont ceux que la requête d'une boucle viserait.
|
|
proxy.Ecoutes = []int{rules.HTTPPort, rules.TLSPort}
|
|
|
|
refusals := NewRefusals(append(DefaultExclusions, splitList(exclude)...))
|
|
front := &TLSFront{CA: ca, Proxy: proxy, Refusals: refusals}
|
|
|
|
httpLn, err := net.Listen("tcp", fmt.Sprintf(":%d", rules.HTTPPort))
|
|
if err != nil {
|
|
return fmt.Errorf(T("écoute HTTP : %w"), err)
|
|
}
|
|
tlsLn, err := net.Listen("tcp", fmt.Sprintf(":%d", rules.TLSPort))
|
|
if err != nil {
|
|
return fmt.Errorf(T("écoute TLS : %w"), err)
|
|
}
|
|
|
|
log.Printf(T("cache : %s"), store.Dir)
|
|
log.Printf(T("autorité : %s (%s)"), CertPath(caDir), ca.Fingerprint())
|
|
log.Printf(T("écoutes : http %d, tls %d"), rules.HTTPPort, rules.TLSPort)
|
|
|
|
errc := make(chan error, 2)
|
|
go func() {
|
|
srv := &http.Server{
|
|
Handler: proxy.handler("http"),
|
|
ReadHeaderTimeout: 30 * time.Second,
|
|
}
|
|
errc <- srv.Serve(httpLn)
|
|
}()
|
|
go func() { errc <- front.Serve(tlsLn) }()
|
|
return <-errc
|
|
}
|
|
|
|
func printStatus(
|
|
store *Store, caDir string, rules RuleSet, miroirStatut *GitMirror,
|
|
) error {
|
|
st, err := store.Stat()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf(T("répertoire : %s\n"), store.Dir)
|
|
fmt.Printf(T("objets : %d\n"), st.Objects)
|
|
fmt.Printf(T("occupation : %s\n"), HumanBytes(st.Bytes))
|
|
if st.Oldest.IsZero() {
|
|
fmt.Printf("%s", T("plus ancien : aucun objet\n"))
|
|
} else {
|
|
fmt.Printf(T("plus ancien : %s\n"), st.Oldest.Format(time.RFC3339))
|
|
}
|
|
fmt.Printf(T("autorité : %s\n"), CertPath(caDir))
|
|
if ca, err := LoadOrCreateCA(caDir); err == nil {
|
|
fmt.Printf(T("empreinte : %s\n"), ca.Fingerprint())
|
|
} else {
|
|
fmt.Printf("%s", T("empreinte : autorité absente\n"))
|
|
}
|
|
if depots, octets := miroirStatut.Occupation(); depots > 0 {
|
|
fmt.Printf(T("dépôts git : %d en miroir, %s\n"),
|
|
depots, HumanBytes(octets))
|
|
}
|
|
if len(rules.Bypass) == 0 {
|
|
fmt.Printf("%s", T("exceptions : aucune\n"))
|
|
} else {
|
|
fmt.Printf(T("exceptions : %d VM soustraite(s) au détournement\n"),
|
|
len(rules.Bypass))
|
|
for _, m := range rules.Bypass {
|
|
fmt.Printf(" %s\n", m)
|
|
}
|
|
}
|
|
fmt.Printf("%s", T("\nAucune éviction n'est écrite : ce cache ne diminue jamais\n"))
|
|
fmt.Printf("%s", T("de lui-même, et il vit sur le disque de l'orchestrateur.\n"))
|
|
return nil
|
|
}
|
|
|
|
func printDryRun(store *Store, caDir string, rules RuleSet) {
|
|
fmt.Printf("%s", T("À blanc — rien n'est écrit, rien n'est posé.\n\n"))
|
|
fmt.Printf(T("Répertoire du cache, créé au démarrage :\n %s\n\n"), store.Dir)
|
|
fmt.Printf(T("Autorité, créée si elle manque :\n %s (clé en 0600)\n\n"),
|
|
CertPath(caDir))
|
|
fmt.Printf("%s", T("Règles nft à poser sur l'hôte :\n"))
|
|
for _, l := range rules.NftLines() {
|
|
fmt.Printf(" %s\n", l)
|
|
}
|
|
fmt.Printf("%s", T("\nÀ défaut de nft :\n"))
|
|
for _, l := range rules.IptablesLines() {
|
|
fmt.Printf(" %s\n", l)
|
|
}
|
|
fmt.Printf(T("\nRetrait :\n %s\n"), rules.NftDeleteLine())
|
|
fmt.Printf("%s", T("\nDans chaque VM qui utilise le cache :\n"))
|
|
for _, f := range []string{"pacman", "apt", "dnf", "zypper"} {
|
|
dir, cmd, bundle, _ := GuestTrustCommand(f)
|
|
fmt.Printf(T(" %-7s %s/erplibre-cache.crt puis %s\n"), f, dir, cmd)
|
|
for _, l := range GuestEnvLines(bundle) {
|
|
fmt.Printf(" %s\n", l)
|
|
}
|
|
}
|
|
}
|
|
|
|
func splitList(s string) []string {
|
|
if strings.TrimSpace(s) == "" {
|
|
return nil
|
|
}
|
|
parts := strings.Split(s, ",")
|
|
out := make([]string, 0, len(parts))
|
|
for _, p := range parts {
|
|
if p = strings.TrimSpace(p); p != "" {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
return out
|
|
}
|