Compare commits

..

11 Commits

Author SHA1 Message Date
Jeff McCune
340715f76c (#36) Provide certs to Cockroach DB and Zitadel with ExternalSecrets
This patch switches CockroachDB to use certs provided by ExternalSecrets
instead of managing Certificate resources in-cluster from the upstream
helm chart.

This paves the way for multi-cluster replication by moving certificates
outside of the lifecycle of the workload cluster cockroach db operates
within.

Closes: #36
2024-03-06 10:38:47 -08:00
Jeff McCune
64ffacfc7a (#36) Add Cockroach Issuer for Zitadel to provisioner cluster
Issuing mtls certs for cockroach db moves to the provisioner cluster so
we can more easily support cross cluster replication in the future.
crdb certs will be synced same as public tls certs, using ExternalSecret
resources.
2024-03-06 09:36:20 -08:00
Nate McCurdy
54acea42cb Merge pull request #37 from holos-run/nate/preflight
Add 'holos preflight' command, check for GitHub CLI
2024-03-06 09:32:54 -08:00
Nate McCurdy
5ef8e75194 Fix Actions warning during Lint by updating golangci-lint-action
Warning:
> Node.js 16 actions are deprecated. Please update the following actions to use Node.js 20: golangci/golangci-lint-action@v3. For more information see: https://github.blog/changelog/2023-09-22-github-actions-transitioning-from-node-16-to-node-20/.
2024-03-05 17:42:30 -08:00
Nate McCurdy
cb2b5c0f49 Add the 'preflight' subcommand; check for GitHub access
This adds a new holos subcommand: preflight

Initially, this just checks that the GitHub CLI is installed and
authenticated.

The preflight command will be used to validate that the user has the
neccessary CLI tools, access, and authorization to start using Holos and
setup a Holos cluster.
2024-03-05 17:40:08 -08:00
Jeff McCune
fd5a2fdbc1 (#36) Sync certs as ExternalSecrets from workload clusters
This patch replaces the httpbin and login cert on the workload clusters
with an ExternalSecret to sync the tls cert from the provisioner
cluster.
2024-03-05 17:05:10 -08:00
Jeff McCune
eb3e272612 (#36) Dynamically generate cluster certs from Platform spec
Each cluster should be more or less identical, configure certs from the
dynamic list of platform clusters.
2024-03-05 16:44:35 -08:00
Nate McCurdy
9f2a51bde8 Move the RunCmd function to the util package
More than one Holos package needs to execute commands, so pull out the
runCmd from builder and move it to the util package.

This commits adds the following to the util package:
* util.RunCmd func
* util.runResult struct
2024-03-05 15:12:14 -08:00
Jeff McCune
2b3b5a4887 (#36) Issue login and httpbin certs
This patch uses cert manager in the provisioner cluster to provision tls
certs for https://login.example.com and https://httpbin.k2.example.com

The certs are not yet synced to the clusters.  Next step is to replace
the Certificate resources with ExternalSecret resources, then remove
cert manager from the workload clusters.
2024-03-05 14:27:37 -08:00
Jeff McCune
7426e8f867 (#36) Move cert-manager to the provisioner cluster
This patch moves certificate management to the provisioner cluster to
centralize all secrets into the highly secured cluster.  This change
also simplifies the architecture in a number of ways:

1. Certificate lives are now completely independent of cluster
   lifecycle.
2. Remove the need for bi-directional sync to save cert secrets.
3. Workload clusters no longer need access to DNS.
2024-03-05 12:51:58 -08:00
Jeff McCune
cf0c455aa2 (#34) Add test for print secret data 2024-03-05 11:14:37 -08:00
30 changed files with 685 additions and 206 deletions

View File

@@ -1,6 +1,7 @@
---
# https://github.com/golangci/golangci-lint-action?tab=readme-ov-file#how-to-use
name: Lint
on:
"on":
push:
branches:
- main
@@ -22,6 +23,6 @@ jobs:
go-version: stable
cache: false
- name: golangci-lint
uses: golangci/golangci-lint-action@v3
uses: golangci/golangci-lint-action@v4
with:
version: latest

View File

@@ -0,0 +1,61 @@
package holos
#PlatformCerts: {
// Globally scoped platform services are defined here.
login: #PlatformCert & {
_name: "login"
_wildcard: true
_description: "Cert for Zitadel oidc identity provider for iam services"
}
// Cluster scoped services are defined here.
for cluster in #Platform.clusters {
"\(cluster.name)-httpbin": #ClusterCert & {
_name: "httpbin"
_cluster: cluster.name
_description: "Test endpoint to verify the service mesh ingress gateway"
}
}
}
// #PlatformCert provisions a cert in the provisioner cluster.
// Workload clusters use ExternalSecret resources to fetch the Secret tls key and cert from the provisioner cluster.
#PlatformCert: #Certificate & {
_name: string
_wildcard: true | *false
metadata: name: string | *_name
metadata: namespace: string | *"istio-ingress"
spec: {
commonName: string | *"\(_name).\(#Platform.org.domain)"
if _wildcard {
dnsNames: [commonName, "*.\(commonName)"]
}
if !_wildcard {
dnsNames: [commonName]
}
secretName: metadata.name
issuerRef: kind: "ClusterIssuer"
issuerRef: name: string | *"letsencrypt"
}
}
// #ClusterCert provisions a cluster specific certificate.
#ClusterCert: #Certificate & {
_name: string
_cluster: string
_wildcard: true | *false
metadata: name: string | *"\(_cluster)-\(_name)"
metadata: namespace: string | *"istio-ingress"
spec: {
commonName: string | *"\(_name).\(_cluster).\(#Platform.org.domain)"
if _wildcard {
dnsNames: [commonName, "*.\(commonName)"]
}
if !_wildcard {
dnsNames: [commonName]
}
secretName: metadata.name
issuerRef: kind: "ClusterIssuer"
issuerRef: name: string | *"letsencrypt"
}
}

View File

@@ -0,0 +1,10 @@
package holos
// Components under this directory are part of this collection
#InputKeys: project: "iam"
// Shared dependencies for all components in this collection.
#DependsOn: _Namespaces
// Common Dependencies
_Namespaces: Namespaces: name: "\(#StageName)-secrets-namespaces"

View File

@@ -0,0 +1,108 @@
package holos
// Manage an Issuer for cockroachdb for zitadel.
// For the iam login service, zitadel connects to cockroach db using tls certs for authz.
// Upstream: "The recommended approach is to use cert-manager for certificate management. For details, refer to Deploy cert-manager for mTLS."
// Refer to https://www.cockroachlabs.com/docs/stable/secure-cockroachdb-kubernetes#deploy-cert-manager-for-mtls
#InputKeys: component: "crdb"
#KubernetesObjects & {
apiObjects: {
Issuer: {
// https://github.com/cockroachdb/helm-charts/blob/3dcf96726ebcfe3784afb526ddcf4095a1684aea/README.md?plain=1#L196-L201
crdb: #Issuer & {
_description: "Issues the self signed root ca cert for cockroach db"
metadata: name: #ComponentName
metadata: namespace: #TargetNamespace
spec: selfSigned: {}
}
"crdb-ca-issuer": #Issuer & {
_description: "Issues mtls certs for cockroach db"
metadata: name: "crdb-ca-issuer"
metadata: namespace: #TargetNamespace
spec: ca: secretName: "cockroach-ca"
}
}
Certificate: {
"crdb-ca-cert": #Certificate & {
_description: "Root CA cert for cockroach db"
metadata: name: "crdb-ca-cert"
metadata: namespace: #TargetNamespace
spec: {
commonName: "root"
isCA: true
issuerRef: group: "cert-manager.io"
issuerRef: kind: "Issuer"
issuerRef: name: "crdb"
privateKey: algorithm: "ECDSA"
privateKey: size: 256
secretName: "cockroach-ca"
subject: organizations: ["Cockroach"]
}
}
"crdb-node": #Certificate & {
metadata: name: "crdb-node"
metadata: namespace: #TargetNamespace
spec: {
commonName: "node"
dnsNames: [
"localhost",
"127.0.0.1",
"crdb-public",
"crdb-public.\(#TargetNamespace)",
"crdb-public.\(#TargetNamespace).svc.cluster.local",
"*.crdb",
"*.crdb.\(#TargetNamespace)",
"*.crdb.\(#TargetNamespace).svc.cluster.local",
]
duration: "876h"
issuerRef: group: "cert-manager.io"
issuerRef: kind: "Issuer"
issuerRef: name: "crdb-ca-issuer"
privateKey: algorithm: "RSA"
privateKey: size: 2048
renewBefore: "168h"
secretName: "cockroachdb-node"
subject: organizations: ["Cockroach"]
usages: ["digital signature", "key encipherment", "server auth", "client auth"]
}
}
"crdb-root-client": #Certificate & {
metadata: name: "crdb-root-client"
metadata: namespace: #TargetNamespace
spec: {
commonName: "root"
duration: "672h"
issuerRef: group: "cert-manager.io"
issuerRef: kind: "Issuer"
issuerRef: name: "crdb-ca-issuer"
privateKey: algorithm: "RSA"
privateKey: size: 2048
renewBefore: "48h"
secretName: "cockroachdb-root"
subject: organizations: ["Cockroach"]
usages: ["digital signature", "key encipherment", "client auth"]
}
}
}
Certificate: zitadel: #Certificate & {
metadata: name: "crdb-zitadel-client"
metadata: namespace: #TargetNamespace
spec: {
commonName: "zitadel"
issuerRef: {
group: "cert-manager.io"
kind: "Issuer"
name: "crdb-ca-issuer"
}
privateKey: algorithm: "RSA"
privateKey: size: 2048
renewBefore: "48h0m0s"
secretName: "cockroachdb-zitadel"
subject: organizations: ["Cockroach"]
usages: ["digital signature", "key encipherment", "client auth"]
}
}
}
}

View File

@@ -0,0 +1,7 @@
package holos
#TargetNamespace: #InstancePrefix + "-zitadel"
#DB: {
Host: "crdb-public"
}

View File

@@ -0,0 +1,20 @@
package holos
// Provision all platform certificates.
#InputKeys: component: "certificates"
// Certificates usually go into the istio-system namespace, but they may go anywhere.
#TargetNamespace: "default"
// Depends on issuers
#DependsOn: _LetsEncrypt
#KubernetesObjects & {
apiObjects: {
for k, obj in #PlatformCerts {
"\(obj.kind)": {
"\(obj.metadata.namespace)/\(obj.metadata.name)": obj
}
}
}
}

View File

@@ -0,0 +1,43 @@
package holos
// https://cert-manager.io/docs/
#TargetNamespace: "cert-manager"
#InputKeys: {
component: "certmanager"
service: "cert-manager"
}
#HelmChart & {
values: #Values & {
installCRDs: true
startupapicheck: enabled: false
// Must not use kube-system on gke autopilot. GKE Warden authz blocks access.
global: leaderElection: namespace: #TargetNamespace
}
namespace: #TargetNamespace
chart: {
name: "cert-manager"
version: "1.14.3"
repository: {
name: "jetstack"
url: "https://charts.jetstack.io"
}
}
}
// https://cloud.google.com/kubernetes-engine/docs/concepts/autopilot-resource-requests#min-max-requests
#PodResources: {
requests: {
cpu: string | *"250m"
memory: string | *"512Mi"
"ephemeral-storage": string | *"100Mi"
}
}
// https://cloud.google.com/kubernetes-engine/docs/how-to/autopilot-spot-pods
#NodeSelector: {
"kubernetes.io/os": "linux"
"cloud.google.com/gke-spot": "true"
}

View File

@@ -1,6 +1,6 @@
package holos
#UpstreamValues: {
#Values: {
// +docs:section=Global
// Default values for cert-manager.
@@ -51,7 +51,7 @@ package holos
leaderElection: {
// Override the namespace used for the leader election lease
namespace: "kube-system"
namespace: string | *"kube-system"
}
}
@@ -246,7 +246,7 @@ package holos
// memory: 32Mi
//
// ref: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/
resources: {}
resources: #PodResources
// Pod Security Context
// ref: https://kubernetes.io/docs/tasks/configure-pod-container/security-context/
@@ -310,9 +310,7 @@ package holos
// This default ensures that Pods are only scheduled to Linux nodes.
// It prevents Pods being scheduled to Windows nodes in a mixed OS cluster.
// +docs:property
nodeSelector: {
"kubernetes.io/os": "linux"
}
nodeSelector: #NodeSelector
// +docs:ignore
ingressShim: {}
@@ -408,7 +406,7 @@ package holos
enabled: true
servicemonitor: {
// Create a ServiceMonitor to add cert-manager to Prometheus
enabled: false
enabled: true | *false
// Specifies the `prometheus` label on the created ServiceMonitor, this is
// used when different Prometheus instances have label selectors matching
@@ -652,7 +650,7 @@ package holos
// memory: 32Mi
//
// ref: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/
resources: {}
resources: #PodResources
// Liveness probe values
// ref: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/#container-probes
@@ -685,9 +683,7 @@ package holos
// This default ensures that Pods are only scheduled to Linux nodes.
// It prevents Pods being scheduled to Windows nodes in a mixed OS cluster.
// +docs:property
nodeSelector: {
"kubernetes.io/os": "linux"
}
nodeSelector: #NodeSelector
// A Kubernetes Affinity, if required; see https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#affinity-v1-core
//
@@ -959,7 +955,7 @@ package holos
// memory: 32Mi
//
// ref: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/
resources: {}
resources: #PodResources
// The nodeSelector on Pods tells Kubernetes to schedule Pods on the nodes with
// matching labels.
@@ -968,9 +964,7 @@ package holos
// This default ensures that Pods are only scheduled to Linux nodes.
// It prevents Pods being scheduled to Windows nodes in a mixed OS cluster.
// +docs:property
nodeSelector: {
"kubernetes.io/os": "linux"
}
nodeSelector: #NodeSelector
// A Kubernetes Affinity, if required; see https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#affinity-v1-core
//
@@ -1098,7 +1092,7 @@ package holos
startupapicheck: {
// Enables the startup api check
enabled: true
enabled: *true | false
// Pod Security Context to be set on the startupapicheck component Pod
// ref: https://kubernetes.io/docs/tasks/configure-pod-container/security-context/
@@ -1151,7 +1145,7 @@ package holos
// memory: 32Mi
//
// ref: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/
resources: {}
resources: #PodResources
// The nodeSelector on Pods tells Kubernetes to schedule Pods on the nodes with
// matching labels.
@@ -1160,9 +1154,7 @@ package holos
// This default ensures that Pods are only scheduled to Linux nodes.
// It prevents Pods being scheduled to Windows nodes in a mixed OS cluster.
// +docs:property
nodeSelector: {
"kubernetes.io/os": "linux"
}
nodeSelector: #NodeSelector
// A Kubernetes Affinity, if required; see https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#affinity-v1-core
//

View File

@@ -0,0 +1,78 @@
package holos
// Lets Encrypt certificate issuers for public tls certs
#InputKeys: component: "letsencrypt"
#TargetNamespace: "cert-manager"
let Name = "letsencrypt"
// The cloudflare api token is platform scoped, not cluster scoped.
#SecretName: "cloudflare-api-token-secret"
// Depends on cert manager
#DependsOn: _CertManager
#KubernetesObjects & {
apiObjects: {
ClusterIssuer: {
letsencrypt: #ClusterIssuer & {
metadata: name: Name
spec: {
acme: {
email: #Platform.org.contact.email
server: "https://acme-v02.api.letsencrypt.org/directory"
privateKeySecretRef: name: Name
solvers: [{
dns01: cloudflare: {
email: #Platform.org.cloudflare.email
apiTokenSecretRef: name: #SecretName
apiTokenSecretRef: key: "api_token"
}}]
}
}
}
letsencryptStaging: #ClusterIssuer & {
metadata: name: Name + "-staging"
spec: {
acme: {
email: #Platform.org.contact.email
server: "https://acme-staging-v02.api.letsencrypt.org/directory"
privateKeySecretRef: name: Name + "-staging"
solvers: [{
dns01: cloudflare: {
email: #Platform.org.cloudflare.email
apiTokenSecretRef: name: #SecretName
apiTokenSecretRef: key: "api_token"
}}]
}
}
}
}
}
}
// _HTTPSolvers are disabled in the provisioner cluster, dns is the method supported by holos.
_HTTPSolvers: {
letsencryptHTTP: #ClusterIssuer & {
metadata: name: Name + "-http"
spec: {
acme: {
email: #Platform.org.contact.email
server: "https://acme-v02.api.letsencrypt.org/directory"
privateKeySecretRef: name: Name
solvers: [{http01: ingress: class: "istio"}]
}
}
}
letsencryptHTTPStaging: #ClusterIssuer & {
metadata: name: Name + "-http-staging"
spec: {
acme: {
email: #Platform.org.contact.email
server: "https://acme-staging-v02.api.letsencrypt.org/directory"
privateKeySecretRef: name: Name + "-staging"
solvers: [{http01: ingress: class: "istio"}]
}
}
}
}

View File

@@ -0,0 +1,13 @@
package holos
// Components under this directory are part of this collection
#InputKeys: project: "mesh"
// Shared dependencies for all components in this collection.
#DependsOn: _Namespaces
// Common Dependencies
_Namespaces: Namespaces: name: "\(#StageName)-secrets-namespaces"
_CertManager: CertManager: name: "\(#InstancePrefix)-certmanager"
_LetsEncrypt: LetsEncrypt: name: "\(#InstancePrefix)-letsencrypt"
_Certificates: Certificates: name: "\(#InstancePrefix)-certificates"

View File

@@ -14,13 +14,7 @@ package holos
}
values: #Values
apiObjects: {
Issuer: {
// https://github.com/cockroachdb/helm-charts/blob/3dcf96726ebcfe3784afb526ddcf4095a1684aea/README.md?plain=1#L196-L201
cockroachdb: #Issuer & {
metadata: name: #ComponentName
metadata: namespace: #TargetNamespace
spec: selfSigned: {}
}
}
ExternalSecret: node: #ExternalSecret & {_name: "cockroachdb-node"}
ExternalSecret: root: #ExternalSecret & {_name: "cockroachdb-root"}
}
}

View File

@@ -478,7 +478,7 @@ package holos
copyCerts: image: "busybox"
certs: {
// Bring your own certs scenario. If provided, tls.init section will be ignored.
provided: false
provided: true | *false
// Secret name for the client root cert.
clientRootSecret: "cockroachdb-root"
// Secret name for node cert.
@@ -487,7 +487,7 @@ package holos
caSecret: "cockroach-ca"
// Enable if the secret is a dedicated TLS.
// TLS secrets are created by cert-mananger, for example.
tlsSecret: false
tlsSecret: true | *false
// Enable if the you want cockroach db to create its own certificates
selfSigner: {
// If set, the cockroach db will generate its own certificates

View File

@@ -10,11 +10,9 @@ package holos
certs: {
// https://github.com/cockroachdb/helm-charts/blob/3dcf96726ebcfe3784afb526ddcf4095a1684aea/README.md?plain=1#L204-L215
selfSigner: enabled: false
certManager: true
certManagerIssuer: {
kind: "Issuer"
name: #ComponentName
}
certManager: false
provided: true
tlsSecret: true
}
}

View File

@@ -24,23 +24,8 @@ let Name = "zitadel"
ExternalSecret: masterkey: #ExternalSecret & {
_name: "zitadel-masterkey"
}
Certificate: zitadel: #Certificate & {
metadata: name: "crdb-zitadel-client"
metadata: namespace: #TargetNamespace
spec: {
commonName: "zitadel"
issuerRef: {
group: "cert-manager.io"
kind: "Issuer"
name: "crdb-ca-issuer"
}
privateKey: algorithm: "RSA"
privateKey: size: 2048
renewBefore: "48h0m0s"
secretName: "cockroachdb-zitadel"
subject: organizations: ["Cockroach"]
usages: ["digital signature", "key encipherment", "client auth"]
}
ExternalSecret: zitadel: #ExternalSecret & {
_name: "cockroachdb-zitadel"
}
VirtualService: zitadel: #VirtualService & {
metadata: name: Name

View File

@@ -1,61 +0,0 @@
package holos
// Lets Encrypt certificate issuers for public tls certs
#InputKeys: component: "certissuers"
#TargetNamespace: "cert-manager"
let Name = "letsencrypt"
// The cloudflare api token is platform scoped, not cluster scoped.
#SecretName: "cloudflare-api-token-secret"
// Depends on cert manager
#DependsOn: _CertManager
#KubernetesObjects & {
apiObjects: {
ClusterIssuer: {
letsencrypt: #ClusterIssuer & {
metadata: name: Name
spec: {
acme: {
email: #Platform.org.contact.email
server: "https://acme-v02.api.letsencrypt.org/directory"
privateKeySecretRef: name: Name + "-istio"
solvers: [{http01: ingress: class: "istio"}]
}
}
}
letsencryptStaging: #ClusterIssuer & {
metadata: name: Name + "-staging"
spec: {
acme: {
email: #Platform.org.contact.email
server: "https://acme-staging-v02.api.letsencrypt.org/directory"
privateKeySecretRef: name: Name + "-staging-istio"
solvers: [{http01: ingress: class: "istio"}]
}
}
}
letsencryptDns: #ClusterIssuer & {
metadata: name: Name + "-dns"
spec: {
acme: {
email: #Platform.org.contact.email
server: "https://acme-v02.api.letsencrypt.org/directory"
privateKeySecretRef: name: Name + "-istio"
solvers: [{
dns01: cloudflare: {
email: #Platform.org.cloudflare.email
apiTokenSecretRef: name: #SecretName
apiTokenSecretRef: key: "api_token"
}}]
}
}
}
}
ExternalSecret: "\(#SecretName)": #ExternalSecret & {
_name: #SecretName
}
}
}

View File

@@ -1,25 +0,0 @@
package holos
// https://cert-manager.io/docs/
#TargetNamespace: "cert-manager"
#InputKeys: {
component: "certmanager"
service: "cert-manager"
}
#HelmChart & {
values: #UpstreamValues & {
installCRDs: true
}
namespace: #TargetNamespace
chart: {
name: "cert-manager"
version: "1.14.3"
repository: {
name: "jetstack"
url: "https://charts.jetstack.io"
}
}
}

View File

@@ -9,32 +9,21 @@ let Name = "gateway"
#TargetNamespace: "istio-ingress"
#DependsOn: _IngressGateway
// TODO: We need to generalize this for multiple services hanging off the default gateway.
let LoginCert = #Certificate & {
metadata: {
name: "login"
namespace: #TargetNamespace
}
spec: {
commonName: "login.\(#Platform.org.domain)"
dnsNames: [commonName]
secretName: metadata.name
issuerRef: kind: "ClusterIssuer"
issuerRef: name: "letsencrypt"
}
}
let LoginCert = #PlatformCerts.login
#KubernetesObjects & {
apiObjects: {
Certificate: login: LoginCert
ExternalSecret: login: #ExternalSecret & {
_name: "login"
}
Gateway: default: #Gateway & {
metadata: name: "default"
metadata: namespace: #TargetNamespace
spec: selector: istio: "ingressgateway"
spec: servers: [
{
hosts: ["prod-iam-zitadel/\(LoginCert.spec.commonName)"]
port: name: "https-prod-iam-zitadel"
hosts: [for dnsName in LoginCert.spec.dnsNames {"prod-iam-zitadel/\(dnsName)"}]
port: name: "https-prod-iam-login"
port: number: 443
port: protocol: "HTTPS"
tls: credentialName: LoginCert.spec.secretName

View File

@@ -14,14 +14,13 @@ let Metadata = {
#TargetNamespace: "istio-ingress"
#DependsOn: _IngressGateway
let Cert = #HTTP01Cert & {
_name: Name
_secret: SecretName
}
let Cert = #PlatformCerts[SecretName]
#KubernetesObjects & {
apiObjects: {
Certificate: httpbin: Cert.object
ExternalSecret: httpbin: #ExternalSecret & {
_name: Cert.spec.secretName
}
Deployment: httpbin: #Deployment & {
metadata: Metadata
spec: selector: matchLabels: MatchLabels
@@ -56,18 +55,18 @@ let Cert = #HTTP01Cert & {
spec: selector: istio: "ingressgateway"
spec: servers: [
{
hosts: ["\(#TargetNamespace)/\(Cert.Host)"]
hosts: [for host in Cert.spec.dnsNames {"\(#TargetNamespace)/\(host)"}]
port: name: "https-\(#InstanceName)"
port: number: 443
port: protocol: "HTTPS"
tls: credentialName: Cert.SecretName
tls: credentialName: Cert.spec.secretName
tls: mode: "SIMPLE"
},
]
}
VirtualService: httpbin: #VirtualService & {
metadata: Metadata
spec: hosts: [Cert.Host]
spec: hosts: [for host in Cert.spec.dnsNames {host}]
spec: gateways: ["\(#TargetNamespace)/\(Name)"]
spec: http: [{route: [{destination: host: Name}]}]
}

129
pkg/cli/preflight/gh.go Normal file
View File

@@ -0,0 +1,129 @@
package preflight
import (
"context"
"fmt"
"strings"
"github.com/holos-run/holos/pkg/logger"
"github.com/holos-run/holos/pkg/util"
"github.com/holos-run/holos/pkg/wrapper"
)
type ghAuthStatusResponse string
// RunGhChecks runs all the preflight checks related to GitHub.
func RunGhChecks(ctx context.Context, cfg *config) error {
if err := cliIsInstalled(ctx); err != nil {
return err
}
if err := cliIsAuthed(ctx, cfg); err != nil {
return err
}
return nil
}
// cliIsInstalled checks if the GitHub CLI is installed.
func cliIsInstalled(ctx context.Context) error {
log := logger.FromContext(ctx)
version, err := getGhVersion(ctx)
if err != nil {
log.WarnContext(ctx, "GitHub CLI (gh) not installed or not in PATH.")
return guideToInstallGh(ctx)
}
log.InfoContext(ctx, "GitHub CLI found", "gh_version", version)
return nil
}
// cliIsAuthed checks if 'gh' is authenticated. If not, 'gh auth login' is run then cliIsAuthed is called again.
func cliIsAuthed(ctx context.Context, cfg *config) error {
log := logger.FromContext(ctx)
status, err := ghAuthStatus(ctx, cfg)
if err != nil || !ghIsAuthenticated(status, cfg) {
log.WarnContext(ctx, "GitHub CLI not authenticated to "+*cfg.githubInstance)
if err := authenticateGh(ctx, cfg); err != nil {
return wrapper.Wrap(fmt.Errorf("failed to authenticate the GitHub CLI to %v: %w", cfg.githubInstance, err))
}
// Re-run this check now that gh should be authenticated.
err := cliIsAuthed(ctx, cfg)
return err
}
log.InfoContext(ctx, "GitHub CLI is authenticated to "+*cfg.githubInstance)
if !ghTokenAllowsRepoCreation(status) {
return wrapper.Wrap(fmt.Errorf("GitHub token does not have the necessary scopes to create a repository"))
}
log.InfoContext(ctx, "GitHub token is able to create a repository")
return nil
}
// ghAuthStatus runs 'gh auth status' and returns the result.
func ghAuthStatus(ctx context.Context, cfg *config) (ghAuthStatusResponse, error) {
log := logger.FromContext(ctx)
out, err := util.RunCmd(ctx, "gh", "auth", "status", "--hostname="+*cfg.githubInstance)
var status ghAuthStatusResponse
if err != nil {
status = ghAuthStatusResponse(out.Stderr.String())
} else {
status = ghAuthStatusResponse(out.Stdout.String())
}
log.DebugContext(ctx, "gh auth status", "gh_auth_status", status)
return status, err
}
// getGhVersion retrieves the version of 'gh'.
func getGhVersion(ctx context.Context) (string, error) {
out, err := util.RunCmd(ctx, "gh", "--version")
if err != nil {
return "", err
}
return strings.Split(out.Stdout.String(), "\n")[0], nil
}
// guideToInstallGh guides the user towards installing the GitHub CLI.
func guideToInstallGh(ctx context.Context) error {
log := logger.FromContext(ctx)
log.WarnContext(ctx, "The GitHub CLI is required to set up Holos. To install it, follow the instructions at: https://github.com/cli/cli#installation")
return wrapper.Wrap(fmt.Errorf("GitHub CLI is not installed"))
}
// authenticateGh runs 'gh auth login' to authenticate the GitHub CLI.
func authenticateGh(ctx context.Context, cfg *config) error {
log := logger.FromContext(ctx)
log.InfoContext(ctx, "Authenticating GitHub CLI with 'gh auth login --hostname="+*cfg.githubInstance+"'. Please follow the prompts.")
err := util.RunInteractiveCmd(ctx, "gh", "auth", "login", "--hostname="+*cfg.githubInstance)
if err != nil {
log.ErrorContext(ctx, "Failed to authenticate GitHub CLI")
return wrapper.Wrap(fmt.Errorf("failed to authenticate GitHub CLI: %w", err))
}
log.InfoContext(ctx, "GitHub CLI has been authenticated.")
return nil
}
// ghIsAuthenticated checks if the GitHub CLI is authenticated and logged in to githubInstance.
func ghIsAuthenticated(status ghAuthStatusResponse, cfg *config) bool {
return strings.Contains(string(status), "Logged in to "+*cfg.githubInstance)
}
// ghTokenAllowsRepoCreation validates that the GitHub CLI is authenticated
// with a token that allows repository creation. This is a naive implementation
// that just checks the output of 'gh auth status' for the presence of the
// 'repo' scope. Note that the 'repo' scope is sufficient to create a secret in
// a repository, so this check also covers that.
// Example token scope line: "- Token scopes: 'admin:public_key', 'gist', 'read:org', 'repo'"
func ghTokenAllowsRepoCreation(status ghAuthStatusResponse) bool {
return strings.Contains(string(status), "'repo'")
}

View File

@@ -0,0 +1,36 @@
package preflight
import "testing"
func TestGhTokenAllowsRepoCreation(t *testing.T) {
testCases := []struct {
name string
status ghAuthStatusResponse
expected bool
}{
{
name: "token has necessary scopes",
status: "- Token: gho_************************************\n- Token scopes: 'gist', 'read:org', 'repo'",
expected: true,
},
{
name: "token has necessary scopes",
status: " - Token scopes: 'gist', 'read:org', 'repo'",
expected: true,
},
{
name: "token does not have necessary scopes",
status: "- Token: gho_************************************\n- Token scopes: 'gist', 'read:org'",
expected: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := ghTokenAllowsRepoCreation(tc.status)
if result != tc.expected {
t.Errorf("expected %v, got %v", tc.expected, result)
}
})
}
}

View File

@@ -0,0 +1,55 @@
package preflight
import (
"flag"
"github.com/spf13/cobra"
"github.com/holos-run/holos/pkg/cli/command"
"github.com/holos-run/holos/pkg/holos"
"github.com/holos-run/holos/pkg/logger"
)
// Config holds configuration parameters for preflight checks.
type config struct {
githubInstance *string
}
// Build the shared configuration and flagset for the preflight command.
func newConfig() (*config, *flag.FlagSet) {
cfg := &config{}
flagSet := flag.NewFlagSet("", flag.ContinueOnError)
cfg.githubInstance = flagSet.String("github-instance", "github.com", "Address of the GitHub instance you want to use")
return cfg, flagSet
}
// New returns the preflight command for the root command.
func New(hc *holos.Config) *cobra.Command {
cfg, flagSet := newConfig()
cmd := command.New("preflight")
cmd.Short = "Run preflight checks to ensure you're ready to use Holos"
cmd.Flags().AddGoFlagSet(flagSet)
cmd.RunE = makePreflightRunFunc(hc, cfg)
return cmd
}
// makePreflightRunFunc returns the internal implementation of the preflight cli command.
func makePreflightRunFunc(_ *holos.Config, cfg *config) command.RunFunc {
return func(cmd *cobra.Command, _ []string) error {
ctx := cmd.Context()
log := logger.FromContext(ctx)
log.Info("Starting preflight checks")
// GitHub checks
if err := RunGhChecks(ctx, cfg); err != nil {
return err
}
// Other checks can be added here
log.Info("Preflight checks complete. Ready to use Holos 🚀")
return nil
}
}

View File

@@ -1,17 +1,20 @@
package cli
import (
"log/slog"
"github.com/spf13/cobra"
"github.com/holos-run/holos/pkg/cli/build"
"github.com/holos-run/holos/pkg/cli/create"
"github.com/holos-run/holos/pkg/cli/get"
"github.com/holos-run/holos/pkg/cli/kv"
"github.com/holos-run/holos/pkg/cli/preflight"
"github.com/holos-run/holos/pkg/cli/render"
"github.com/holos-run/holos/pkg/cli/txtar"
"github.com/holos-run/holos/pkg/holos"
"github.com/holos-run/holos/pkg/logger"
"github.com/holos-run/holos/pkg/version"
"github.com/spf13/cobra"
"log/slog"
)
// New returns a new root *cobra.Command for command line execution.
@@ -51,6 +54,7 @@ func New(cfg *holos.Config) *cobra.Command {
rootCmd.AddCommand(render.New(cfg))
rootCmd.AddCommand(get.New(cfg))
rootCmd.AddCommand(create.New(cfg))
rootCmd.AddCommand(preflight.New(cfg))
// Maybe not needed?
rootCmd.AddCommand(txtar.New(cfg))

View File

@@ -0,0 +1,7 @@
# Print the data key by default
holos get secret zitadel-admin
stdout '^{$'
stdout '^ "url": "https://login.example.com"'
stdout '^ "username": "zitadel-admin@zitadel.login.example.com"'
stdout '^ "password": "Password1!"'
stdout '^}$'

View File

@@ -4,23 +4,22 @@
package builder
import (
"bytes"
"context"
"cuelang.org/go/cue/build"
"fmt"
"github.com/holos-run/holos"
"github.com/holos-run/holos/pkg/logger"
"github.com/holos-run/holos/pkg/util"
"github.com/holos-run/holos/pkg/wrapper"
"log/slog"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"cuelang.org/go/cue/build"
"cuelang.org/go/cue/cuecontext"
"cuelang.org/go/cue/load"
"github.com/holos-run/holos"
"github.com/holos-run/holos/pkg/logger"
"github.com/holos-run/holos/pkg/util"
"github.com/holos-run/holos/pkg/wrapper"
)
const (
@@ -214,13 +213,13 @@ func (r *Result) kustomize(ctx context.Context) error {
}
// Run kustomize.
kOut, err := runCmd(ctx, "kubectl", "kustomize", tempDir)
kOut, err := util.RunCmd(ctx, "kubectl", "kustomize", tempDir)
if err != nil {
log.ErrorContext(ctx, kOut.stderr.String())
log.ErrorContext(ctx, kOut.Stderr.String())
return wrapper.Wrap(err)
}
// Replace the accumulated output
r.accumulatedOutput = kOut.stdout.String()
r.accumulatedOutput = kOut.Stdout.String()
return nil
}
@@ -379,25 +378,6 @@ func (b *Builder) findCueMod() (dir holos.PathCueMod, err error) {
return dir, nil
}
type runResult struct {
stdout *bytes.Buffer
stderr *bytes.Buffer
}
func runCmd(ctx context.Context, name string, args ...string) (result runResult, err error) {
result = runResult{
stdout: new(bytes.Buffer),
stderr: new(bytes.Buffer),
}
cmd := exec.CommandContext(ctx, name, args...)
cmd.Stdout = result.stdout
cmd.Stderr = result.stderr
log := logger.FromContext(ctx)
log.DebugContext(ctx, "run: "+name, "name", name, "args", args)
err = cmd.Run()
return
}
// runHelm provides the values produced by CUE to helm template and returns
// the rendered kubernetes api objects in the result.
func runHelm(ctx context.Context, hc *HelmChart, r *Result, path holos.PathComponent) error {
@@ -411,15 +391,15 @@ func runHelm(ctx context.Context, hc *HelmChart, r *Result, path holos.PathCompo
if isNotExist(cachedChartPath) {
// Add repositories
repo := hc.Chart.Repository
out, err := runCmd(ctx, "helm", "repo", "add", repo.Name, repo.URL)
out, err := util.RunCmd(ctx, "helm", "repo", "add", repo.Name, repo.URL)
if err != nil {
log.ErrorContext(ctx, "could not run helm", "stderr", out.stderr.String(), "stdout", out.stdout.String())
log.ErrorContext(ctx, "could not run helm", "stderr", out.Stderr.String(), "stdout", out.Stdout.String())
return wrapper.Wrap(fmt.Errorf("could not run helm repo add: %w", err))
}
// Update repository
out, err = runCmd(ctx, "helm", "repo", "update", repo.Name)
out, err = util.RunCmd(ctx, "helm", "repo", "update", repo.Name)
if err != nil {
log.ErrorContext(ctx, "could not run helm", "stderr", out.stderr.String(), "stdout", out.stdout.String())
log.ErrorContext(ctx, "could not run helm", "stderr", out.Stderr.String(), "stdout", out.Stdout.String())
return wrapper.Wrap(fmt.Errorf("could not run helm repo update: %w", err))
}
// Cache the chart
@@ -443,9 +423,9 @@ func runHelm(ctx context.Context, hc *HelmChart, r *Result, path holos.PathCompo
// Run charts
chart := hc.Chart
helmOut, err := runCmd(ctx, "helm", "template", "--values", valuesPath, "--namespace", hc.Namespace, "--kubeconfig", "/dev/null", "--version", chart.Version, chart.Name, cachedChartPath)
helmOut, err := util.RunCmd(ctx, "helm", "template", "--values", valuesPath, "--namespace", hc.Namespace, "--kubeconfig", "/dev/null", "--version", chart.Version, chart.Name, cachedChartPath)
if err != nil {
stderr := helmOut.stderr.String()
stderr := helmOut.Stderr.String()
lines := strings.Split(stderr, "\n")
for _, line := range lines {
if strings.HasPrefix(line, "Error:") {
@@ -455,7 +435,7 @@ func runHelm(ctx context.Context, hc *HelmChart, r *Result, path holos.PathCompo
return wrapper.Wrap(fmt.Errorf("could not run helm template: %w", err))
}
r.accumulatedOutput = helmOut.stdout.String()
r.accumulatedOutput = helmOut.Stdout.String()
return nil
}
@@ -486,11 +466,11 @@ func cacheChart(ctx context.Context, path holos.PathComponent, chartDir string,
defer remove(ctx, cacheTemp)
chartName := fmt.Sprintf("%s/%s", chart.Repository.Name, chart.Name)
helmOut, err := runCmd(ctx, "helm", "pull", "--destination", cacheTemp, "--untar=true", "--version", chart.Version, chartName)
helmOut, err := util.RunCmd(ctx, "helm", "pull", "--destination", cacheTemp, "--untar=true", "--version", chart.Version, chartName)
if err != nil {
return wrapper.Wrap(fmt.Errorf("could not run helm pull: %w", err))
}
log.Debug("helm pull", "stdout", helmOut.stdout, "stderr", helmOut.stderr)
log.Debug("helm pull", "stdout", helmOut.Stdout, "stderr", helmOut.Stderr)
cachePath := filepath.Join(string(path), chartDir)
if err := os.Rename(cacheTemp, cachePath); err != nil {

56
pkg/util/run.go Normal file
View File

@@ -0,0 +1,56 @@
package util
import (
"bytes"
"context"
"os"
"os/exec"
"github.com/holos-run/holos/pkg/logger"
)
// runResult holds the stdout and stderr of a command.
type RunResult struct {
Stdout *bytes.Buffer
Stderr *bytes.Buffer
}
// RunCmd runs a command within a context, captures its output, provides debug
// logging, and returns the result.
// Example:
//
// result, err := RunCmd(ctx, "echo", "hello")
// if err != nil {
// return wrapper.Wrap(err)
// }
// fmt.Println(result.Stdout.String())
//
// Output:
//
// "hello\n"
func RunCmd(ctx context.Context, name string, args ...string) (result RunResult, err error) {
result = RunResult{
Stdout: new(bytes.Buffer),
Stderr: new(bytes.Buffer),
}
cmd := exec.CommandContext(ctx, name, args...)
cmd.Stdout = result.Stdout
cmd.Stderr = result.Stderr
log := logger.FromContext(ctx)
log.DebugContext(ctx, "running: "+name, "name", name, "args", args)
err = cmd.Run()
return result, err
}
// RunInteractiveCmd runs a command within a context but allows the command to
// accept stdin interactively from the user. The caller is expected to handle
// errors.
func RunInteractiveCmd(ctx context.Context, name string, args ...string) error {
cmd := exec.CommandContext(ctx, name, args...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
log := logger.FromContext(ctx)
log.DebugContext(ctx, "running: "+name, "name", name, "args", args)
return cmd.Run()
}

View File

@@ -1 +1 @@
51
53