gitea:acmeEmail sat in Pulumi.prod.yaml, which this public repository publishes, and was then copied into instance metadata. It now lives in a gitea-acme-email secret instead, read by the VM when it renders the Caddyfile. - scripts/bootstrap.sh creates the secret empty and prints how to set it, the same as github-pat: the address is chosen, not generated. - Pulumi grants the VM secretAccessor on it and nothing more. It is kept out of secrets.Names, whose members also get secretVersionAdder and are mapped to `gitea generate secret` by vm/bootstrap.sh. - The gitea:acmeEmail config key and the acme-email metadata entry are gone. - vm/bootstrap.sh renders the whole `email` directive. If the secret is unreadable it renders a comment instead and warns: Caddy still issues certificates under an account with no contact address, whereas an empty `email` would fail to parse and leave nothing serving TLS. Same directive-or-comment pattern as CADDY_PUBLISH_PORTS and CADDY_SYSCTL. README setup gains the secret step, plus two that were missing: ADC login (Pulumi's GCS backend and provider do not use the gcloud login), and exporting PULUMI_CONFIG_PASSPHRASE from Secret Manager before `stack init`. Without the latter, init prompts for a new passphrase and the stack is encrypted with a key Cloud Build's infra trigger never sees. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
213 lines
7.5 KiB
Go
213 lines
7.5 KiB
Go
// Package compute builds the data disk, its snapshot schedule, and the instance.
|
|
package compute
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"strconv"
|
|
|
|
gcpcompute "github.com/pulumi/pulumi-gcp/sdk/v9/go/gcp/compute"
|
|
"github.com/pulumi/pulumi-gcp/sdk/v9/go/gcp/serviceaccount"
|
|
"github.com/pulumi/pulumi-gcp/sdk/v9/go/gcp/storage"
|
|
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
|
|
|
|
"gitea-infra/pkg/config"
|
|
"gitea-infra/pkg/network"
|
|
"gitea-infra/pkg/registry"
|
|
)
|
|
|
|
// DataDiskDeviceName is what the disk shows up as under /dev/disk/by-id/google-*
|
|
// inside the guest. vm/bootstrap.sh mounts it by UUID, but it needs this name to
|
|
// find the device the first time, before there is a filesystem to have a UUID.
|
|
const DataDiskDeviceName = "gitea-data"
|
|
|
|
type Instance struct {
|
|
VM *gcpcompute.Instance
|
|
Disk *gcpcompute.Disk
|
|
}
|
|
|
|
func New(
|
|
ctx *pulumi.Context,
|
|
cfg *config.Config,
|
|
net *network.Network,
|
|
sa *serviceaccount.Account,
|
|
buckets *storage.Bucket,
|
|
backupBucket *storage.Bucket,
|
|
configHash string,
|
|
bootstrapPath string,
|
|
deps []pulumi.Resource,
|
|
) (*Instance, error) {
|
|
opts := pulumi.DependsOn(deps)
|
|
|
|
// A separate disk from the boot disk, so replacing the instance -- for a
|
|
// machine-type change, an image refresh, anything -- never touches the
|
|
// repositories. Protect(true) makes an accidental delete a two-step mistake.
|
|
//
|
|
// This is the disk that holds everything you would miss: repositories, the
|
|
// SQLite database, and LFS objects. gitea:dataDiskGb sizes it. The boot disk
|
|
// is separate and sized by gitea:bootDiskGb; growing that one buys nothing.
|
|
// A pd-balanced disk can be grown in place later, never shrunk.
|
|
disk, err := gcpcompute.NewDisk(ctx, "gitea-data", &gcpcompute.DiskArgs{
|
|
Name: pulumi.String("gitea-data"),
|
|
Zone: pulumi.String(cfg.Zone),
|
|
Size: pulumi.Int(cfg.DataDiskGB),
|
|
Type: pulumi.String("pd-balanced"),
|
|
Description: pulumi.String("Gitea repositories, SQLite database, and LFS objects"),
|
|
}, opts, pulumi.Protect(true))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// The safety net for the weekly unattended-reboot window, and for everything
|
|
// else that goes wrong at 3am.
|
|
policy, err := gcpcompute.NewResourcePolicy(ctx, "gitea-snapshots", &gcpcompute.ResourcePolicyArgs{
|
|
Name: pulumi.String("gitea-daily-snapshots"),
|
|
Region: pulumi.String(cfg.Region),
|
|
SnapshotSchedulePolicy: &gcpcompute.ResourcePolicySnapshotSchedulePolicyArgs{
|
|
Schedule: &gcpcompute.ResourcePolicySnapshotSchedulePolicyScheduleArgs{
|
|
DailySchedule: &gcpcompute.ResourcePolicySnapshotSchedulePolicyScheduleDailyScheduleArgs{
|
|
DaysInCycle: pulumi.Int(1),
|
|
StartTime: pulumi.String("03:00"),
|
|
},
|
|
},
|
|
RetentionPolicy: &gcpcompute.ResourcePolicySnapshotSchedulePolicyRetentionPolicyArgs{
|
|
MaxRetentionDays: pulumi.Int(14),
|
|
// Keep the history even if the disk itself is deleted.
|
|
OnSourceDiskDelete: pulumi.String("KEEP_AUTO_SNAPSHOTS"),
|
|
},
|
|
SnapshotProperties: &gcpcompute.ResourcePolicySnapshotSchedulePolicySnapshotPropertiesArgs{
|
|
StorageLocations: pulumi.String(cfg.Region),
|
|
Labels: pulumi.StringMap{"app": pulumi.String("gitea")},
|
|
},
|
|
},
|
|
}, opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if _, err := gcpcompute.NewDiskResourcePolicyAttachment(ctx, "gitea-data-snapshots", &gcpcompute.DiskResourcePolicyAttachmentArgs{
|
|
Name: policy.Name,
|
|
Disk: disk.Name,
|
|
Zone: pulumi.String(cfg.Zone),
|
|
}, opts); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
startupScript, err := os.ReadFile(bootstrapPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading %s: %w", bootstrapPath, err)
|
|
}
|
|
|
|
imageGitea := fmt.Sprintf("%s/%s/%s/gitea:prod", cfg.ARHost(), cfg.Project, registry.RepoID)
|
|
imageCaddy := fmt.Sprintf("%s/%s/%s/caddy:prod", cfg.ARHost(), cfg.Project, registry.RepoID)
|
|
|
|
vm, err := gcpcompute.NewInstance(ctx, "gitea-vm", &gcpcompute.InstanceArgs{
|
|
Name: pulumi.String("gitea-vm"),
|
|
Zone: pulumi.String(cfg.Zone),
|
|
MachineType: pulumi.String(cfg.MachineType),
|
|
Tags: pulumi.ToStringArray([]string{network.Tag}),
|
|
Description: pulumi.String("Gitea, Caddy, and podman quadlets on AlmaLinux 10"),
|
|
|
|
BootDisk: &gcpcompute.InstanceBootDiskArgs{
|
|
InitializeParams: &gcpcompute.InstanceBootDiskInitializeParamsArgs{
|
|
// Image family, not a pinned image: new VMs pick up the monthly
|
|
// refreshed AlmaLinux build automatically.
|
|
Image: pulumi.String("almalinux-cloud/almalinux-10"),
|
|
Size: pulumi.Int(cfg.BootDiskGB),
|
|
Type: pulumi.String("pd-balanced"),
|
|
},
|
|
},
|
|
|
|
AttachedDisks: gcpcompute.InstanceAttachedDiskArray{
|
|
&gcpcompute.InstanceAttachedDiskArgs{
|
|
Source: disk.ID(),
|
|
DeviceName: pulumi.String(DataDiskDeviceName),
|
|
Mode: pulumi.String("READ_WRITE"),
|
|
},
|
|
},
|
|
|
|
NetworkInterfaces: gcpcompute.InstanceNetworkInterfaceArray{
|
|
&gcpcompute.InstanceNetworkInterfaceArgs{
|
|
Network: net.Network.ID(),
|
|
Subnetwork: net.Subnetwork.ID(),
|
|
AccessConfigs: gcpcompute.InstanceNetworkInterfaceAccessConfigArray{
|
|
&gcpcompute.InstanceNetworkInterfaceAccessConfigArgs{
|
|
NatIp: net.Address.Address,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
|
|
ServiceAccount: &gcpcompute.InstanceServiceAccountArgs{
|
|
Email: sa.Email,
|
|
// cloud-platform plus per-resource IAM, rather than a hand-rolled
|
|
// scope list: scopes are a coarse legacy filter and IAM is where the
|
|
// real restrictions live.
|
|
Scopes: pulumi.ToStringArray([]string{"cloud-platform"}),
|
|
},
|
|
|
|
ShieldedInstanceConfig: &gcpcompute.InstanceShieldedInstanceConfigArgs{
|
|
EnableSecureBoot: pulumi.Bool(true),
|
|
EnableVtpm: pulumi.Bool(true),
|
|
EnableIntegrityMonitoring: pulumi.Bool(true),
|
|
},
|
|
|
|
Metadata: pulumi.StringMap{
|
|
// OS Login means SSH access follows IAM instead of metadata keys.
|
|
"enable-oslogin": pulumi.String("TRUE"),
|
|
"google-logging-enabled": pulumi.String("true"),
|
|
|
|
"config-bucket": buckets.Name,
|
|
"backup-bucket": backupBucket.Name,
|
|
"gcp-project": pulumi.String(cfg.Project),
|
|
"gcp-region": pulumi.String(cfg.Region),
|
|
"ar-host": pulumi.String(cfg.ARHost()),
|
|
|
|
// Floating :prod tags -- AutoUpdate=registry has nothing to poll if
|
|
// these are digests.
|
|
"image-gitea": pulumi.String(imageGitea),
|
|
"image-caddy": pulumi.String(imageCaddy),
|
|
|
|
"domain": pulumi.String(cfg.Domain),
|
|
"app-name": pulumi.String(cfg.AppName),
|
|
"require-signin-view": pulumi.String(strconv.FormatBool(cfg.RequireSigninView)),
|
|
|
|
// Drives Coraza's SecRuleEngine and, from the same value, whether
|
|
// the fail2ban jail acting on WAF verdicts is enabled.
|
|
"waf-mode": pulumi.String(cfg.WAFMode),
|
|
|
|
"podman-subnet": pulumi.String(cfg.PodmanCIDR),
|
|
"podman-gateway": pulumi.String(gatewayFor(cfg.PodmanCIDR)),
|
|
|
|
"data-disk-device": pulumi.String("/dev/disk/by-id/google-" + DataDiskDeviceName),
|
|
|
|
// Not read by the guest -- it exists so `gcloud compute instances
|
|
// describe` shows which revision of vm/ the box was built against.
|
|
"config-hash": pulumi.String(configHash),
|
|
},
|
|
|
|
MetadataStartupScript: pulumi.String(string(startupScript)),
|
|
|
|
// Lets Pulumi change machine type or metadata in place instead of
|
|
// refusing the update.
|
|
AllowStoppingForUpdate: pulumi.Bool(true),
|
|
|
|
Labels: pulumi.StringMap{"app": pulumi.String("gitea")},
|
|
}, opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &Instance{VM: vm, Disk: disk}, nil
|
|
}
|
|
|
|
// gatewayFor returns the .1 address of a /24. The podman network's gateway has
|
|
// to be stated explicitly alongside the pinned subnet.
|
|
func gatewayFor(cidr string) string {
|
|
var a, b, c, d, bits int
|
|
if _, err := fmt.Sscanf(cidr, "%d.%d.%d.%d/%d", &a, &b, &c, &d, &bits); err != nil {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("%d.%d.%d.1", a, b, c)
|
|
}
|