clustercfg

package
v0.28.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 30, 2026 License: Apache-2.0 Imports: 22 Imported by: 0

Documentation

Overview

Package clustercfg is used to parse an byte array and returns a ZarfCluster

Index

Constants

View Source
const AgeIdentityFileEnvVar = "CARGOSHIP_AGE_IDENTITY_FILE"

AgeIdentityFileEnvVar is the environment variable checked for the path to an age identity file when no --age-identity-file flag is given. It holds one path: a list would need a separator, and every separator worth choosing is legal in a file name.

View Source
const AgeRecipientsEnvVar = "CARGOSHIP_AGE_RECIPIENTS"

AgeRecipientsEnvVar is the environment variable checked for age recipients when no --age-recipient or --age-recipients-file flag is given. It holds whitespace-separated public keys, which is unambiguous in a way a list of paths is not, since a public key contains no whitespace.

View Source
const AnsibleVaultPasswordEnvVar = "ANSIBLE_VAULT_PASSWORD"

AnsibleVaultPasswordEnvVar is a secondary environment variable checked for the Ansible Vault password, used by ansible-vault itself. VaultPasswordEnvVar takes precedence when both are set.

View Source
const VaultPasswordEnvVar = "CARGOSHIP_VAULT_PASSWORD"

VaultPasswordEnvVar is the environment variable checked for the Ansible Vault password when no --vault-password-file flag is given.

Variables

View Source
var ErrAlreadyEncrypted = errors.New("value is encrypted already")

ErrAlreadyEncrypted reports that the value at the requested path is ciphertext already, in either format, so encrypting it again would bury the plaintext under a second layer that nothing unwraps.

View Source
var ErrNoAgeIdentities = errors.New("no age identities configured")

ErrNoAgeIdentities reports that an age value has to be decrypted but no identity was configured.

View Source
var ErrNoAgeRecipients = errors.New("no age recipients configured")

ErrNoAgeRecipients reports that an age encryption was asked for with no recipients to encrypt to.

View Source
var ErrNoKeyMaterial = errors.New("no vault password and no age recipients were provided")

ErrNoKeyMaterial reports that nothing was configured to encrypt with: no vault password and no age recipients.

View Source
var ErrNotEncrypted = errors.New("value is not encrypted")

ErrNotEncrypted reports that the value at the requested path carries neither format's header, so it is plaintext and there is nothing to decrypt. Rewriting it anyway would be a no-op that still rewrote the file, which is worth saying out loud rather than reporting as success.

View Source
var ErrWrappedTwice = errors.New("value is encrypted more than once")

ErrWrappedTwice reports that the value at the requested path is ciphertext whose plaintext is itself ciphertext -- what encrypt-path --force produces. Rekeying it would move the outer layer onto the new key and leave the inner one on the old, which no single key can read back, so it is refused rather than half done. The layers need not be in the same format for that to be true.

Functions

func AgeRecipientsIn

func AgeRecipientsIn(r io.Reader) ([]string, error)

AgeRecipientsIn reads an identity file and returns the public keys of the identities it holds.

An identity that is not an X25519 key is an error rather than a line quietly passed over. The answer this is asked for is "who can read the values encrypted to this key", and a short answer to that question is worse than no answer: the operator acts on it.

An SSH private key is refused for the same reason and answered rather than derived. agessh's recipients have no text encoding to print, and ssh-keygen already wrote the public key into the ".pub" file beside the key, so pointing at that is a better answer than any this could compute.

func CanonicalYAMLPath

func CanonicalYAMLPath(yamlPath string) (string, error)

CanonicalYAMLPath returns yamlPath as go-yaml spells it, so that the several ways of writing one path -- "$.spec.x", ".spec.x" and "spec.x" -- come back as the same string. A caller given a list of paths needs that to tell whether two of them name the same value, which is worth catching before the first one has been rewritten.

func DecryptAtPath

func DecryptAtPath(src []byte, yamlPath string, k *Keyring) ([]byte, error)

DecryptAtPath returns src with the ciphertext at yamlPath replaced by its plaintext, preserving every other byte of src for the same reasons, and by the same means, as EncryptAtPath. The value's own header says which format it is in.

The plaintext is written in whichever scalar style holds it faithfully: plain where YAML allows it, a literal block for something multi-line such as a PEM certificate, and a quoted string for anything the other two would change on the way back in. If the value is plaintext already, it returns ErrNotEncrypted.

func DecryptRegistryAuth

func DecryptRegistryAuth(dis *cluster.ZarfCluster, k *Keyring) error

DecryptRegistryAuth decrypts any encrypted Username, Password, or Token fields on dis.Spec.Config.Registries in place, along with an inline TLS CA certificate given the same way. Fields that carry neither ciphertext header are left untouched.

Each field is decrypted according to its own header, so one registry can hold an Ansible Vault password beside an age-encrypted token. That is what makes migrating between the two formats a sequence of ordinary edits rather than a cutover.

A CA certificate is public and does not need encrypting, but accepting one encrypted means a document can be encrypted as a whole without cargoship rejecting the parts that did not have to be. The decrypted TLS settings are validated here, since load time saw only ciphertext.

func DecryptValue

func DecryptValue(value string, k *Keyring) (string, error)

DecryptValue decrypts a single encrypted value with the keyring, returning the plaintext EncryptValue was given.

The value's header says which format it is in, so this reads whatever a document holds without being told, and does not care which format the same keyring would encrypt with.

DecryptRegistryAuth is what an apply runs; this is for the operator reading a value back out of a configuration by hand.

func EncryptAtPath

func EncryptAtPath(src []byte, yamlPath string, k *Keyring, reencrypt bool) ([]byte, error)

EncryptAtPath returns src with the scalar at yamlPath replaced by its ciphertext, written as a literal block scalar. Every other byte of src is preserved -- comments, key order, quoting and indentation elsewhere in the document all survive, so the result is still the operator's file rather than a re-rendering of it.

Which of the two formats is written is the keyring's decision; the splice is the same either way, because both formats are ASCII text a literal block scalar holds unchanged.

That byte-level splice is deliberate. Replacing the node in the parsed document and printing it back would be shorter, but go-yaml re-indents a multi-line literal by slicing each continuation line, which silently truncates ciphertext nested inside a sequence -- which is where every registry credential lives.

If the value is ciphertext already, it returns ErrAlreadyEncrypted unless reencrypt is set.

func EncryptValue

func EncryptValue(value string, k *Keyring) (string, error)

EncryptValue encrypts value with the keyring, producing a string suitable for use as a registry auth field (see DecryptRegistryAuth).

Which format comes out is the keyring's decision, made once in EncryptFormat, so that every command that writes ciphertext writes it the same way.

func GenerateAgeIdentity

func GenerateAgeIdentity() (*age.X25519Identity, error)

GenerateAgeIdentity returns a new X25519 key pair.

X25519 rather than a choice of algorithms because it is the one age generates: the post-quantum recipient type cargoship accepts on the encryption side comes from somewhere else, and there is nothing to pick between here.

func Parse

func Parse(_ context.Context, b []byte) (_ cluster.ZarfCluster, err error)

Parse parses the yaml passed as a byte slice and applies schema migrations.

func ParseRecipient

func ParseRecipient(key string) (age.Recipient, error)

ParseRecipient parses one public key, which may be a native age recipient or an SSH public key in the authorized_keys form.

func ParseRecipients

func ParseRecipients(r io.Reader) ([]age.Recipient, []string, error)

ParseRecipients reads a file of public keys, one per line, ignoring blank lines and lines that begin with '#'.

age.ParseRecipients would do this, except that it fails the whole file on the first line it does not recognise, and an SSH key is such a line. Dispatching line by line is what lets one file hold both kinds, which is the point of accepting SSH keys in the age flags at all.

A line that parses as neither kind fails the file, naming the line. Skipping it would encrypt to fewer recipients than the operator listed, and the person who discovers that is the one who cannot decrypt.

Each accepted line comes back beside the recipient it parsed to, because an age.Recipient cannot be turned back into text: agessh's types carry no text encoding, which is the same reason AgeRecipientsIn refuses an SSH private key. The text has to be kept here or not at all, and what keeps it is the record written into the document.

func PathIsDecryptable

func PathIsDecryptable(yamlPath string) bool

PathIsDecryptable reports whether yamlPath names a field cargoship decrypts at apply time. A value encrypted anywhere else reaches the host as ciphertext, because nothing unwraps it.

func RecordedRecipients

func RecordedRecipients(src []byte) ([]string, string, bool, error)

RecordedRecipients returns the age recipients src records having been encrypted to, along with when the record was written, and reports false when the document carries no record at all.

What comes back is a claim the document makes about itself and nothing more. An age header names no recipient -- the X25519 stanza is deliberately anonymous -- so there is no way to check this list against the ciphertext beside it, and nothing in cargoship tries. It is compared against the recipients an operator names, so that a mismatch can be reported; it is never used as key material, and a rekey never encrypts to a key that came out of here. See docs/agent/choice-age-encryption.md.

A record whose shape is not a list of strings is an error rather than an absent record. A caller should report it and carry on: the file is still encrypted correctly, it is only this note about it that has been damaged.

func RekeyAtPath

func RekeyAtPath(src []byte, yamlPath string, from, to *Keyring) ([]byte, error)

RekeyAtPath returns src with the ciphertext at yamlPath re-wrapped under the "to" keyring, preserving every other byte of src by the same splice EncryptAtPath uses.

The two keyrings do not have to hold the same format, and that is the whole migration path between them: read with a vault password, write to age recipients, in one pass that never puts the plaintext on disk.

The plaintext is never rendered back into the document. The value is decrypted, encrypted again, and spliced in as ciphertext, so the plaintext exists only as a string in memory -- which is the whole point of rekeying as one operation rather than a decrypt-file followed by an encrypt-file, where the file holds the plaintext in between. It also means a value renderScalar would refuse to write back, one that is not valid UTF-8, rekeys without trouble, because nothing here has to express it as YAML.

A value that is plaintext returns ErrNotEncrypted, and one wrapped more than once ErrWrappedTwice.

func ResolveVaultPassword

func ResolveVaultPassword(passwordFile string) (string, error)

ResolveVaultPassword returns the Ansible Vault password to use for decrypting registry credentials. If passwordFile is set, its contents are read and used. Otherwise it falls back to the CARGOSHIP_VAULT_PASSWORD environment variable, then to ANSIBLE_VAULT_PASSWORD. An empty return value with a nil error means no password was configured.

func SameRecipients

func SameRecipients(a, b []string) bool

SameRecipients reports whether two lists name the same set of age public keys.

Order is not part of the comparison. A recipients file's order is the order a person wrote their team's keys in, which is worth keeping in the record, but it says nothing about who can read the file -- so reporting a reordered list as drift would be noise over a difference that is not one.

Nor is an SSH key's authorized_keys comment. The same key given once as "ssh-ed25519 AAAA..." and once as "ssh-ed25519 AAAA... alice@laptop" is one key, and the comment is kept in the record because it says whose key it is, not because it distinguishes two of them.

func VerifyRegistryAuth

func VerifyRegistryAuth(dis *cluster.ZarfCluster, k *Keyring) error

VerifyRegistryAuth reports whether every encrypted registry value in dis can be decrypted with the keyring, and whether what comes out is usable, leaving dis unchanged.

The values are only needed once the engine configuration is written, which is several phases into an apply -- long after cargoship has connected to every host, and on a sync, after it has started draining nodes. A key that was never supplied, or one that does not fit the document, is worth finding out about before any of that happens rather than partway through it, so a command calls this as soon as it has resolved the keyring.

It matters more for age than it did for vault. An age value does not record which recipients it was encrypted to, so this pre-flight is the only thing that catches a document encrypted to a key nobody on this machine holds.

func WriteAgeIdentity

func WriteAgeIdentity(w io.Writer, id *age.X25519Identity) error

WriteAgeIdentity writes id to w in the format age-keygen writes, so that the file is readable by the age distribution as well as by cargoship.

Matching that format exactly is the point rather than a nicety. An operator who later wants to read a value with 'age --decrypt', or to hand the key to something else that speaks age, would be stranded by a file only cargoship understands, and the whole reason for supporting age is that it is not a format of cargoship's own.

The public key is written as a comment beside the private one because it is the only copy an operator has once the terminal output is gone, and 'keygen -y' reads it back from here.

Types

type Format

type Format string

Format names one of the two ciphertext formats cargoship reads. The values read as they should in an error message, which is most of what they are for.

const (
	// FormatVault is Ansible Vault ciphertext, keyed by a shared password.
	FormatVault Format = "Ansible Vault"
	// FormatAge is armored age ciphertext, keyed by a recipient's public key.
	FormatAge Format = "age"
)

func FormatOf

func FormatOf(value string) (Format, bool)

FormatOf reports which format value is encrypted in, and false when it is plaintext.

type KeyOptions

type KeyOptions struct {
	// VaultPasswordFile is the path passed to --vault-password-file, if any.
	VaultPasswordFile string
	// AgeIdentityFiles are the paths passed to --age-identity-file, which decrypt.
	AgeIdentityFiles []string
	// AgeRecipients are the public keys passed to --age-recipient, which encrypt.
	AgeRecipients []string
	// AgeRecipientFiles are the paths passed to --age-recipients-file, each holding public keys.
	AgeRecipientFiles []string
	// SSHPassphrase is asked for the passphrase of an encrypted SSH identity. Leaving it nil says
	// the caller has nowhere to ask, which makes such a key an error rather than a hang.
	SSHPassphrase PassphraseFunc
}

KeyOptions is the key material a command was given, before any of it has been read or parsed.

type Keyring

type Keyring struct {
	// contains filtered or unexported fields
}

Keyring holds the key material cargoship encrypts and decrypts registry credentials with.

One keyring can carry both formats at once, because one configuration can hold both. That is not only a migration state: an apply reads whatever the document has, so a file that keeps a legacy vaulted credential beside age-encrypted ones works indefinitely.

The age types are unexported so that the packages this is threaded through -- pkg/action and pkg/phase, which only ever pass it along -- do not take on a dependency on the age module.

func NewVaultKeyring

func NewVaultKeyring(password string) *Keyring

NewVaultKeyring returns a keyring holding only an Ansible Vault password, for a caller that has the password itself rather than the flags it came from.

func ResolveKeyring

func ResolveKeyring(o KeyOptions) (*Keyring, error)

ResolveKeyring reads every piece of key material o names and returns it as one keyring.

Nothing is discovered implicitly. An age identity is read from the path given or from CARGOSHIP_AGE_IDENTITY_FILE and nowhere else -- not from ~/.config/sops/age/keys.txt, not from any other conventional location -- for the reason ResolveVaultPassword never prompts: an apply that succeeds on one operator's machine because of a file the config never mentions is an apply nobody can reason about.

func (*Keyring) CanDecrypt

func (k *Keyring) CanDecrypt(format Format) bool

CanDecrypt reports whether the keyring holds what it takes to decrypt a value in format.

func (*Keyring) Empty

func (k *Keyring) Empty() bool

Empty reports whether the keyring holds no key material at all, which is how a command tells "nothing was configured" from "what was configured does not fit this value".

func (*Keyring) EncryptFormat

func (k *Keyring) EncryptFormat() (Format, error)

EncryptFormat reports the format EncryptValue will write, and an error when the keyring gives no way to choose one.

age recipients beat a vault password that came from the environment. CARGOSHIP_VAULT_PASSWORD and ANSIBLE_VAULT_PASSWORD are typically set once in a shell profile on a machine already using vault, so an operator who passes --age-recipient there is asking for age, not for a complaint about a variable they set months ago.

Two explicit flags is an error instead of a third precedence rule. A value is written in one format, and picking which one on the operator's behalf means writing a secret under a key they did not choose -- the one case worth stopping for rather than guessing well.

func (*Keyring) RecipientStrings

func (k *Keyring) RecipientStrings() []string

RecipientStrings returns the age recipients the keyring encrypts to, as the text they were given as. It is a copy, so a caller writing it into a document cannot reach back into the keyring.

Returning strings rather than the recipients themselves is what keeps pkg/action and pkg/phase free of the age module, for the reason the age fields are unexported at all. These are not key material: they are the public half of a key pair, recorded so that a person reading the file can see whose keys it was encrypted to.

func (*Keyring) RekeyTarget

func (k *Keyring) RekeyTarget(newVaultPassword string) (*Keyring, bool, error)

RekeyTarget returns the keyring a rekey should write with, given this keyring as the one the file is read with, and reports whether that target is a different key from the one the file already carries.

The rules are the ones rekey has always had, widened by one case. An explicit new vault password is a rotation. age recipients are a rotation too, and are how a configuration moves from Ansible Vault to age: read with the old password, write to the recipients, in a single pass that never puts the plaintext on disk. Neither given means the target is the key the file already uses, which re-salts every value rather than rotating it.

Both given is an error. --new-vault-password-file and the age recipients each name the key to write, and honouring one would mean silently ignoring the other.

type PassphraseFunc

type PassphraseFunc func(path string) ([]byte, error)

PassphraseFunc is asked for the passphrase protecting the SSH private key at path.

It exists so that this package does no terminal I/O of its own: the commands own the terminal and supply the prompt, and a caller with nowhere to prompt supplies nothing. A nil PassphraseFunc is therefore an error rather than a prompt, which is what makes an encrypted key fail immediately in CI instead of hanging on a read that will never be answered.

type Skip

type Skip struct {
	// Path is the credential's path in the document, as go-yaml spells it.
	Path string
	// Reason reads as the predicate of a sentence whose subject is the value at Path, so that a
	// command can render it as a message without restating what it is about.
	Reason string
}

Skip records a registry credential a whole-file rewrite left as it was, and why, so that the command can say so rather than reporting a run that did nothing as a run with nothing to do.

An empty Reason means the skip is not worth reporting: a path that is absent, a value that is empty, or one already in exactly the state the command was asked to put it in. That convention keeps the uninteresting majority silent without every caller having to filter them out.

func DecryptConfig

func DecryptConfig(src []byte, k *Keyring) ([]byte, []string, []Skip, error)

DecryptConfig is the inverse of EncryptConfig: it decrypts every encrypted registry credential in src and returns the rewritten document along with the paths it changed. A field that is not ciphertext is skipped, so what comes back is a document with no encrypted credentials left in it however many of them there were to begin with.

It returns no skips worth reporting. The only thing it skips is a value that is plaintext already, which is the state it was asked to produce.

A document holding both formats decrypts in one pass, provided the keyring carries the key material for both, because each value is read according to its own header.

func EncryptConfig

func EncryptConfig(src []byte, k *Keyring, reencrypt bool) ([]byte, []string, []Skip, error)

EncryptConfig encrypts every registry credential in src that cargoship decrypts at apply time -- each registry's auth.user, auth.pass, auth.token and tls.ca -- and returns the rewritten document, the paths it changed, and the credentials it left alone that are worth saying something about. Everything else is left exactly as it was, by the same byte-level splice EncryptAtPath uses.

A field that is absent, empty, or encrypted already is skipped rather than treated as an error, so running this over a configuration that is partly encrypted finishes the job, and running it twice changes nothing the second time. Pass reencrypt to wrap values that are ciphertext already.

Most of those skips are the point of the command and are returned with no reason, which means they are not worth reporting. The ones that come back with a reason are the cases where what the operator asked for and what happened may differ -- see skipReason.

A value already under Ansible Vault, being encrypted with an Ansible Vault password, has to be one that password can read, and it is an error when it is not. Skipping it quietly would leave the file holding ciphertext under two different vault passwords, and an apply decrypts a registry's fields with one password, so the result would be a configuration no password can read back -- found out about at apply time, several phases in, rather than here.

There is no equivalent check for age, and cannot be. Encrypting needs only a public key, which cannot decrypt anything, and an age header records no recipient identifier -- the X25519 stanza is deliberately anonymous, so that ciphertext does not reveal who can read it. Nor is the check wanted: the invariant it protects belongs to vault, where one password covers the document. age decrypts against every identity an operator holds, so a document encrypted to several recipients is an ordinary document rather than a broken one.

func RekeyConfig

func RekeyConfig(src []byte, from, to *Keyring) ([]byte, []string, []Skip, error)

RekeyConfig re-wraps every encrypted registry credential in src under the "to" keyring and returns the rewritten document along with the paths it changed. A field that is absent, empty, or plaintext is skipped, so what comes back is a document whose ciphertext is all under one key and whose plaintext was never written anywhere.

Like DecryptConfig it returns no skips worth reporting: a value it passes over is one there was nothing to rekey about.

This is also how a configuration moves between the two formats: read it with the old vault password, write it to age recipients. Nothing else is needed, because the write side already asks the keyring which format to produce.

Every value it touches has to be readable with the "from" keyring, and it is an error when one is not. There is no way to rekey a file already holding ciphertext under two vault passwords into a working state -- an apply reads a registry's fields with a single password -- so stopping is the only answer that does not produce a configuration no password can read back.

Unlike EncryptConfig this is not idempotent, and cannot be: both formats randomize every encryption, so a second run rewrites the same values to different ciphertext. Passing the same keyring as both old and new is that property put to use rather than a mistake: every value comes back under fresh randomness, readable with the key the file already carried.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL