feat: add set-testing subcommand to pin a node to testing
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was canceled

This commit is contained in:
2026-10-23 21:34:07 +11:00
parent 2800eca378
commit 7877f1eab6
4 changed files with 269 additions and 1 deletions
+17
View File
@@ -29,6 +29,23 @@ exec `node_terminus` fails safe rather than compiling an empty catalog.
`http://encapi.encapi.svc.cluster.local` (the in-cluster service).
- The HTTP request has a 10s timeout.
### Pinning a host to `testing`
```
encapic set-testing <certname>
```
Sets the node's environment to `testing` in encapi (requires the encapi
oidc-auth release). A `testing` node's ENC omits `environment`, so any
`puppet agent --environment X` is honoured; the host stays in `testing` until
the next terragrunt-enc apply resets it.
It mints a JWT from `${VAULT_ADDR}/v1/identity/oidc/token/${ENCAPI_OIDC_ROLE}`
(defaults `https://vault.service.consul:8200`, `encapi-testing`) using
`VAULT_TOKEN` or `~/.vault-token`, trusting `VAULT_CACERT` when set, then
`PUT`s `{"environment":"testing"}` to
`${ENCAPI_URL}/api/v1/nodes/<certname>/environment`.
## Behaviour (drop-in for the python ENC)
encapic reproduces the previous python script byte-for-byte:
+4 -1
View File
@@ -58,8 +58,11 @@ func run(args []string, out io.Writer) error {
_, err := fmt.Fprintf(out, "encapic %s\n", version)
return err
}
if len(args) == 3 && args[1] == "set-testing" {
return setTesting(args[2], out)
}
if len(args) != 2 {
return fmt.Errorf("usage: %s <certname>", args[0])
return fmt.Errorf("usage: %s <certname> | %s set-testing <certname>", args[0], args[0])
}
certname := args[1]
+140
View File
@@ -0,0 +1,140 @@
package main
import (
"bytes"
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
)
const defaultVaultAddr = "https://vault.service.consul:8200"
// setTesting pins certname to the testing environment via encapi, using a
// Vault identity OIDC token for the encapi-testing role.
func setTesting(certname string, out io.Writer) error {
client, err := vaultClient()
if err != nil {
return err
}
jwt, err := mintToken(client)
if err != nil {
return err
}
baseURL := os.Getenv("ENCAPI_URL")
if baseURL == "" {
baseURL = defaultBaseURL
}
u := strings.TrimRight(baseURL, "/") + "/api/v1/nodes/" + url.PathEscape(certname) + "/environment"
req, err := http.NewRequest(http.MethodPut, u, bytes.NewBufferString(`{"environment":"testing"}`))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+jwt)
req.Header.Set("Content-Type", "application/json")
if _, err := do(client, req); err != nil {
return err
}
_, err = fmt.Fprintf(out, "%s set to testing\n", certname)
return err
}
func mintToken(client *http.Client) (string, error) {
addr := os.Getenv("VAULT_ADDR")
if addr == "" {
addr = defaultVaultAddr
}
role := os.Getenv("ENCAPI_OIDC_ROLE")
if role == "" {
role = "encapi-testing"
}
vaultToken, err := readVaultToken()
if err != nil {
return "", err
}
req, err := http.NewRequest(http.MethodGet, strings.TrimRight(addr, "/")+"/v1/identity/oidc/token/"+url.PathEscape(role), nil)
if err != nil {
return "", err
}
req.Header.Set("X-Vault-Token", vaultToken)
body, err := do(client, req)
if err != nil {
return "", err
}
var resp struct {
Data struct {
Token string `json:"token"`
} `json:"data"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return "", fmt.Errorf("decode vault token response: %w", err)
}
if resp.Data.Token == "" {
return "", errors.New("vault returned no OIDC token")
}
return resp.Data.Token, nil
}
func readVaultToken() (string, error) {
if t := os.Getenv("VAULT_TOKEN"); t != "" {
return t, nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
b, err := os.ReadFile(filepath.Join(home, ".vault-token"))
if err != nil {
return "", fmt.Errorf("no VAULT_TOKEN and cannot read ~/.vault-token: %w", err)
}
return strings.TrimSpace(string(b)), nil
}
func vaultClient() (*http.Client, error) {
client := &http.Client{Timeout: httpTimeout}
caFile := os.Getenv("VAULT_CACERT")
if caFile == "" {
return client, nil
}
pem, err := os.ReadFile(caFile)
if err != nil {
return nil, fmt.Errorf("read VAULT_CACERT: %w", err)
}
pool, err := x509.SystemCertPool()
if err != nil {
pool = x509.NewCertPool()
}
if !pool.AppendCertsFromPEM(pem) {
return nil, fmt.Errorf("no certificates in VAULT_CACERT %s", caFile)
}
client.Transport = &http.Transport{
Proxy: http.ProxyFromEnvironment,
TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12},
}
return client, nil
}
func do(client *http.Client, req *http.Request) ([]byte, error) {
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("%s %s: %w", req.Method, req.URL, err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response from %s: %w", req.URL, err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("%s %s returned HTTP %d: %s",
req.Method, req.URL, resp.StatusCode, strings.TrimSpace(string(body)))
}
return body, nil
}
+108
View File
@@ -0,0 +1,108 @@
package main
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func fakeVault(t *testing.T, status int, body string) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/v1/identity/oidc/token/encapi-testing" {
http.NotFound(w, r)
return
}
if r.Header.Get("X-Vault-Token") != "vt" {
http.Error(w, `{"errors":["permission denied"]}`, http.StatusForbidden)
return
}
w.WriteHeader(status)
_, _ = io.WriteString(w, body)
}))
t.Cleanup(srv.Close)
return srv
}
type seenPut struct{ path, auth, body string }
func fakeEncapi(t *testing.T, status int, seen *seenPut) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
if r.Method == http.MethodPut {
*seen = seenPut{r.URL.Path, r.Header.Get("Authorization"), string(b)}
}
w.WriteHeader(status)
_, _ = io.WriteString(w, `{"error":"forbidden"}`)
}))
t.Cleanup(srv.Close)
return srv
}
func TestSetTestingSuccess(t *testing.T) {
var seen seenPut
t.Setenv("VAULT_ADDR", fakeVault(t, 200, `{"data":{"token":"jwt123"}}`).URL)
t.Setenv("VAULT_TOKEN", "vt")
t.Setenv("ENCAPI_URL", fakeEncapi(t, 200, &seen).URL)
var out bytes.Buffer
if err := run([]string{"encapic", "set-testing", "host.example"}, &out); err != nil {
t.Fatalf("run: %v", err)
}
want := seenPut{"/api/v1/nodes/host.example/environment", "Bearer jwt123", `{"environment":"testing"}`}
if seen != want {
t.Errorf("PUT = %+v, want %+v", seen, want)
}
}
func TestSetTestingVaultTokenFile(t *testing.T) {
var seen seenPut
home := t.TempDir()
if err := os.WriteFile(filepath.Join(home, ".vault-token"), []byte("vt\n"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("HOME", home)
t.Setenv("VAULT_TOKEN", "")
t.Setenv("VAULT_ADDR", fakeVault(t, 200, `{"data":{"token":"jwt123"}}`).URL)
t.Setenv("ENCAPI_URL", fakeEncapi(t, 204, &seen).URL)
if err := run([]string{"encapic", "set-testing", "host.example"}, io.Discard); err != nil {
t.Fatalf("run: %v", err)
}
if seen.auth != "Bearer jwt123" {
t.Errorf("auth = %q", seen.auth)
}
}
func TestSetTestingVaultError(t *testing.T) {
var seen seenPut
t.Setenv("VAULT_ADDR", fakeVault(t, 400, `{"errors":["role not found"]}`).URL)
t.Setenv("VAULT_TOKEN", "vt")
t.Setenv("ENCAPI_URL", fakeEncapi(t, 200, &seen).URL)
err := run([]string{"encapic", "set-testing", "host.example"}, io.Discard)
if err == nil || !strings.Contains(err.Error(), "HTTP 400") || !strings.Contains(err.Error(), "role not found") {
t.Fatalf("err = %v, want HTTP 400 with body", err)
}
if seen.path != "" {
t.Errorf("encapi called despite vault failure: %+v", seen)
}
}
func TestSetTestingEncapiForbidden(t *testing.T) {
var seen seenPut
t.Setenv("VAULT_ADDR", fakeVault(t, 200, `{"data":{"token":"jwt123"}}`).URL)
t.Setenv("VAULT_TOKEN", "vt")
t.Setenv("ENCAPI_URL", fakeEncapi(t, 403, &seen).URL)
err := run([]string{"encapic", "set-testing", "host.example"}, io.Discard)
if err == nil || !strings.Contains(err.Error(), "HTTP 403") || !strings.Contains(err.Error(), "forbidden") {
t.Fatalf("err = %v, want HTTP 403 with body", err)
}
}