commands_admin

package
v0.38.2 Latest Latest
Warning

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

Go to latest
Published: Oct 12, 2026 License: Apache-2.0 Imports: 27 Imported by: 0

Documentation

Index

Constants

This section is empty.

Variables

View Source
var AdminCmd = &cli.Command{
	Name:        "admin",
	Usage:       "Admin Operations",
	Description: "Run administration operations for the server.",
	MaxArgs:     cli.NoArgs,
	Flags: append(cmdutil.ClientFlags(false), []cli.Flag{

		&cli.BoolFlag{
			Name:         "mysql-enabled",
			Usage:        "Enable MySQL database backend.",
			ConfigPath:   []string{"server.mysql.enabled"},
			EnvVars:      []string{config.CONFIG_ENV_PREFIX + "_MYSQL_ENABLED"},
			DefaultValue: false,
			Global:       true,
		},
		&cli.StringFlag{
			Name:         "mysql-host",
			Usage:        "The MySQL host to connect to.",
			ConfigPath:   []string{"server.mysql.host"},
			EnvVars:      []string{config.CONFIG_ENV_PREFIX + "_MYSQL_HOST"},
			DefaultValue: "localhost",
			Global:       true,
		},
		&cli.IntFlag{
			Name:         "mysql-port",
			Usage:        "The MySQL port to connect to.",
			ConfigPath:   []string{"server.mysql.port"},
			EnvVars:      []string{config.CONFIG_ENV_PREFIX + "_MYSQL_PORT"},
			DefaultValue: 3306,
			Global:       true,
		},
		&cli.StringFlag{
			Name:         "mysql-user",
			Usage:        "The MySQL user to connect as.",
			ConfigPath:   []string{"server.mysql.user"},
			EnvVars:      []string{config.CONFIG_ENV_PREFIX + "_MYSQL_USER"},
			DefaultValue: "root",
			Global:       true,
		},
		&cli.StringFlag{
			Name:         "mysql-password",
			Usage:        "The MySQL password to use.",
			ConfigPath:   []string{"server.mysql.password"},
			EnvVars:      []string{config.CONFIG_ENV_PREFIX + "_MYSQL_PASSWORD"},
			DefaultValue: "",
			Global:       true,
		},
		&cli.StringFlag{
			Name:         "mysql-database",
			Usage:        "The MySQL database to use.",
			ConfigPath:   []string{"server.mysql.database"},
			EnvVars:      []string{config.CONFIG_ENV_PREFIX + "_MYSQL_DATABASE"},
			DefaultValue: "knot",
			Global:       true,
		},
		&cli.IntFlag{
			Name:         "mysql-connection-max-idle",
			Usage:        "The maximum number of idle connections in the connection pool.",
			ConfigPath:   []string{"server.mysql.connection_max_idle"},
			EnvVars:      []string{config.CONFIG_ENV_PREFIX + "_MYSQL_CONNECTION_MAX_IDLE"},
			DefaultValue: 10,
			Global:       true,
		},
		&cli.IntFlag{
			Name:         "mysql-connection-max-open",
			Usage:        "The maximum number of open connections to the database.",
			ConfigPath:   []string{"server.mysql.connection_max_open"},
			EnvVars:      []string{config.CONFIG_ENV_PREFIX + "_MYSQL_CONNECTION_MAX_OPEN"},
			DefaultValue: 100,
			Global:       true,
		},
		&cli.IntFlag{
			Name:         "mysql-connection-max-lifetime",
			Usage:        "The maximum amount of time in minutes a connection may be reused.",
			ConfigPath:   []string{"server.mysql.connection_max_lifetime"},
			EnvVars:      []string{config.CONFIG_ENV_PREFIX + "_MYSQL_CONNECTION_MAX_LIFETIME"},
			DefaultValue: 5,
			Global:       true,
		},

		&cli.BoolFlag{
			Name:         "badgerdb-enabled",
			Usage:        "Enable BadgerDB database backend.",
			ConfigPath:   []string{"server.badgerdb.enabled"},
			EnvVars:      []string{config.CONFIG_ENV_PREFIX + "_BADGERDB_ENABLED"},
			DefaultValue: false,
			Global:       true,
		},
		&cli.StringFlag{
			Name:         "badgerdb-path",
			Usage:        "The path to the BadgerDB database.",
			ConfigPath:   []string{"server.badgerdb.path"},
			EnvVars:      []string{config.CONFIG_ENV_PREFIX + "_BADGERDB_PATH"},
			DefaultValue: "./badger",
			Global:       true,
		},

		&cli.BoolFlag{
			Name:         "redis-enabled",
			Usage:        "Enable Redis database backend.",
			ConfigPath:   []string{"server.redis.enabled"},
			EnvVars:      []string{config.CONFIG_ENV_PREFIX + "_REDIS_ENABLED"},
			DefaultValue: false,
			Global:       true,
		},
		&cli.StringSliceFlag{
			Name:         "redis-hosts",
			Usage:        "The redis server(s), can be specified multiple times.",
			ConfigPath:   []string{"server.redis.hosts"},
			EnvVars:      []string{config.CONFIG_ENV_PREFIX + "_REDIS_HOSTS"},
			DefaultValue: []string{"localhost:6379"},
			Global:       true,
		},
		&cli.StringFlag{
			Name:         "redis-password",
			Usage:        "The password to use for the redis server.",
			ConfigPath:   []string{"server.redis.password"},
			EnvVars:      []string{config.CONFIG_ENV_PREFIX + "_REDIS_PASSWORD"},
			DefaultValue: "",
			Global:       true,
		},
		&cli.IntFlag{
			Name:         "redis-db",
			Usage:        "The redis database to use.",
			ConfigPath:   []string{"server.redis.db"},
			EnvVars:      []string{config.CONFIG_ENV_PREFIX + "_REDIS_DB"},
			DefaultValue: 0,
			Global:       true,
		},
		&cli.StringFlag{
			Name:         "redis-master-name",
			Usage:        "The name of the master to use for failover clients.",
			ConfigPath:   []string{"server.redis.master_name"},
			EnvVars:      []string{config.CONFIG_ENV_PREFIX + "_REDIS_MASTER_NAME"},
			DefaultValue: "",
			Global:       true,
		},
		&cli.StringFlag{
			Name:         "redis-key-prefix",
			Usage:        "The prefix to use for all keys in the redis database.",
			ConfigPath:   []string{"server.redis.key_prefix"},
			EnvVars:      []string{config.CONFIG_ENV_PREFIX + "_REDIS_KEY_PREFIX"},
			DefaultValue: "",
			Global:       true,
		},
		&cli.StringFlag{
			Name:         "encrypt",
			Usage:        "The encryption key to use for encrypting stored variables.",
			ConfigPath:   []string{"server.encrypt"},
			EnvVars:      []string{config.CONFIG_ENV_PREFIX + "_ENCRYPT"},
			DefaultValue: "",
			Global:       true,
		},
	}...),
	Commands: []*cli.Command{
		RenameZoneCmd,
		SetPasswordCmd,
		ResetTOTPCmd,
		BackupCmd,
		RestoreCmd,
		FileCmd,
		RefreshBaseImagesCmd,
		AdminTemplateCmd,
	},
	PreRun: func(ctx context.Context, cmd *cli.Command) (context.Context, error) {
		var err error

		if ctx, err = cmd.GetRootCmd().PreRun(ctx, cmd); err != nil {
			return ctx, err
		}

		serverCfg := &config.ServerConfig{
			EncryptionKey: cmd.GetString("encrypt"),
			MySQL: config.MySQLConfig{
				Enabled:               cmd.GetBool("mysql-enabled"),
				Host:                  cmd.GetString("mysql-host"),
				Port:                  cmd.GetInt("mysql-port"),
				User:                  cmd.GetString("mysql-user"),
				Password:              cmd.GetString("mysql-password"),
				Database:              cmd.GetString("mysql-database"),
				ConnectionMaxIdle:     cmd.GetInt("mysql-connection-max-idle"),
				ConnectionMaxOpen:     cmd.GetInt("mysql-connection-max-open"),
				ConnectionMaxLifetime: cmd.GetInt("mysql-connection-max-lifetime"),
			},
			BadgerDB: config.BadgerDBConfig{
				Enabled: cmd.GetBool("badgerdb-enabled"),
				Path:    cmd.GetString("badgerdb-path"),
			},
			Redis: config.RedisConfig{
				Enabled:    cmd.GetBool("redis-enabled"),
				Hosts:      cmd.GetStringSlice("redis-hosts"),
				Password:   cmd.GetString("redis-password"),
				DB:         cmd.GetInt("redis-db"),
				MasterName: cmd.GetString("redis-master-name"),
				KeyPrefix:  cmd.GetString("redis-key-prefix"),
			},
			Audit: config.AuditConfig{
				Retention: cmd.GetInt("audit-retention"),
			},
		}
		config.SetServerConfig(serverCfg)

		return ctx, nil
	},
}
View Source
var AdminTemplateCmd = &cli.Command{
	Name:        "template",
	Usage:       "Import and export templates",
	Description: "Import and export templates as portable YAML. Requires a token with the manage templates permission.",
	MaxArgs:     cli.NoArgs,
	Commands: []*cli.Command{
		TemplateExportCmd,
		TemplateImportCmd,
	},
}

AdminTemplateCmd groups template import and export. Both call API endpoints that require the manage templates permission, so they live under admin; the user-facing template list stays under `knot template`.

View Source
var BackupCmd = &cli.Command{
	Name:  "backup",
	Usage: "Back up the server to a folder",
	Description: `Back up everything the server holds, while it runs, into a folder: users with their tokens and spaces, templates, groups, roles, scripts, configuration, the audit log, and file storage with every file's content.

The backup is taken through the server's API, by a user holding the Backup Server permission (the Backup User role), so it can run from anywhere. Choose the server with --server and --token, or --alias for one in the configuration file's client.connection section; with neither, the default alias is used.

The folder holds one file of records for each kind, a manifest written when the backup is complete, and file content stored by checksum under content/. Run it again into the same folder to refresh the backup: only content not already there is copied. Records are kept as they were when the backup started; content is copied afterwards, so a file replaced meanwhile may have lost its old content, which is reported.

Selecting kinds backs up only those; with none selected everything is. --encrypt-key encrypts the records, which include password hashes and API tokens; file content is stored as it is, so protect the folder.

Restore the folder into a new server with knot admin restore, and list or restore files from it with knot admin file ls and knot admin file restore.`,
	Arguments: []cli.Argument{
		&cli.StringArg{Name: "backupdir", Usage: "The folder to back up into", Required: true},
	},
	MaxArgs: cli.NoArgs,
	Flags: []cli.Flag{
		&cli.BoolFlag{Name: "templates", Aliases: []string{"t"}, Usage: "Backup templates", ConfigPath: []string{"backup.templates"}, EnvVars: []string{config.CONFIG_ENV_PREFIX + "_BACKUP_TEMPLATES"}},
		&cli.BoolFlag{Name: "template-vars", Aliases: []string{"v"}, Usage: "Backup template variables", ConfigPath: []string{"backup.template_vars"}, EnvVars: []string{config.CONFIG_ENV_PREFIX + "_BACKUP_TEMPLATE_VARS"}},
		&cli.BoolFlag{Name: "volumes", Aliases: []string{"l"}, Usage: "Backup volumes", ConfigPath: []string{"backup.volumes"}, EnvVars: []string{config.CONFIG_ENV_PREFIX + "_BACKUP_VOLUMES"}},
		&cli.BoolFlag{Name: "groups", Aliases: []string{"g"}, Usage: "Backup groups", ConfigPath: []string{"backup.groups"}, EnvVars: []string{config.CONFIG_ENV_PREFIX + "_BACKUP_GROUPS"}},
		&cli.BoolFlag{Name: "roles", Aliases: []string{"r"}, Usage: "Backup roles", ConfigPath: []string{"backup.roles"}, EnvVars: []string{config.CONFIG_ENV_PREFIX + "_BACKUP_ROLES"}},
		&cli.BoolFlag{Name: "spaces", Aliases: []string{"s"}, Usage: "Backup user spaces", ConfigPath: []string{"backup.spaces"}, EnvVars: []string{config.CONFIG_ENV_PREFIX + "_BACKUP_SPACES"}},
		&cli.BoolFlag{Name: "users", Aliases: []string{"u"}, Usage: "Backup users", ConfigPath: []string{"backup.users"}, EnvVars: []string{config.CONFIG_ENV_PREFIX + "_BACKUP_USERS"}},
		&cli.BoolFlag{Name: "tokens", Aliases: []string{"k"}, Usage: "Backup user tokens", ConfigPath: []string{"backup.tokens"}, EnvVars: []string{config.CONFIG_ENV_PREFIX + "_BACKUP_TOKENS"}},
		&cli.BoolFlag{Name: "cfg-values", Aliases: []string{"o"}, Usage: "Backup configuration values", ConfigPath: []string{"backup.cfg_values"}, EnvVars: []string{config.CONFIG_ENV_PREFIX + "_BACKUP_CFG_VALUES"}},
		&cli.BoolFlag{Name: "scripts", Aliases: []string{"c"}, Usage: "Backup scripts", ConfigPath: []string{"backup.scripts"}, EnvVars: []string{config.CONFIG_ENV_PREFIX + "_BACKUP_SCRIPTS"}},
		&cli.BoolFlag{Name: "skills", Aliases: []string{"i"}, Usage: "Backup skills", ConfigPath: []string{"backup.skills"}, EnvVars: []string{config.CONFIG_ENV_PREFIX + "_BACKUP_SKILLS"}},
		&cli.BoolFlag{Name: "commands", Usage: "Backup slash commands", ConfigPath: []string{"backup.commands"}, EnvVars: []string{config.CONFIG_ENV_PREFIX + "_BACKUP_COMMANDS"}},
		&cli.BoolFlag{Name: "responses", Aliases: []string{"p"}, Usage: "Backup responses", ConfigPath: []string{"backup.responses"}, EnvVars: []string{config.CONFIG_ENV_PREFIX + "_BACKUP_RESPONSES"}},
		&cli.BoolFlag{Name: "audit-logs", Usage: "Backup audit logs", ConfigPath: []string{"backup.audit_logs"}, EnvVars: []string{config.CONFIG_ENV_PREFIX + "_BACKUP_AUDIT_LOGS"}},
		&cli.BoolFlag{Name: "files", Usage: "Backup file storage: buckets, files and their content", ConfigPath: []string{"backup.files"}, EnvVars: []string{config.CONFIG_ENV_PREFIX + "_BACKUP_FILES"}},
		&cli.BoolFlag{Name: "no-content", Usage: "With file storage, back up the records of buckets and files but not their content.", EnvVars: []string{config.CONFIG_ENV_PREFIX + "_BACKUP_NO_CONTENT"}},
		&cli.BoolFlag{Name: "prune", Usage: "With file storage and content, remove content from the folder that no file refers to any more, so the folder mirrors the server.", EnvVars: []string{config.CONFIG_ENV_PREFIX + "_BACKUP_PRUNE"}},
		&cli.BoolFlag{Name: "all", Aliases: []string{"a"}, Usage: "Backup everything", ConfigPath: []string{"backup.all"}, EnvVars: []string{config.CONFIG_ENV_PREFIX + "_BACKUP_ALL"}, DefaultValue: true},
		&cli.StringFlag{Name: "limit-user", Usage: "Limit the backup to a specific user by username.", ConfigPath: []string{"backup.limit_user"}, EnvVars: []string{config.CONFIG_ENV_PREFIX + "_BACKUP_LIMIT_USER"}},
		&cli.StringFlag{Name: "limit-template", Usage: "Limit the backup to a specific template by name.", ConfigPath: []string{"backup.limit_template"}, EnvVars: []string{config.CONFIG_ENV_PREFIX + "_BACKUP_LIMIT_TEMPLATE"}},
		&cli.StringFlag{Name: "encrypt-key", Aliases: []string{"e"}, Usage: "Encrypt the records with this key, which must be 32 bytes long.", ConfigPath: []string{"backup.encrypt_key"}, EnvVars: []string{config.CONFIG_ENV_PREFIX + "_BACKUP_ENCRYPT_KEY"}},
	},
	Run: func(ctx context.Context, cmd *cli.Command) error {
		dir := cmd.GetStringArg("backupdir")
		key := cmd.GetString("encrypt-key")
		if key != "" && len(key) != 32 {
			return errors.New("the encryption key must be 32 bytes long")
		}

		client, err := adminClient(cmd)
		if err != nil {
			return err
		}
		info, err := client.GetBackupInfo(ctx)
		if err != nil {
			return fmt.Errorf("couldn't start the backup: %w", cmdutil.CleanErr(err))
		}

		selected := map[string]bool{}
		explicit := false
		for _, f := range backupFlags {
			if cmd.GetBool(f.flag) {
				explicit = true
				for _, k := range f.kinds {
					selected[k] = true
				}
			}
		}
		if !explicit {
			for _, k := range info.Kinds {
				selected[k] = true
			}
		}
		if !info.Files {
			if selected["file-buckets"] && explicit {
				return errors.New("this server has no file storage to back up")
			}
			delete(selected, "file-buckets")
			delete(selected, "file-objects")
		}
		if len(selected) == 0 {
			return errors.New("nothing to back up")
		}
		withContent := selected["file-objects"] && !cmd.GetBool("no-content")

		if err := prepareBackupDir(dir); err != nil {
			return err
		}
		fmt.Printf("Backing up the server (knot %s) to %s\n", info.Version, dir)

		params := url.Values{}
		if v := cmd.GetString("limit-user"); v != "" {
			params.Set("limit_user", v)
		}
		if v := cmd.GetString("limit-template"); v != "" {
			params.Set("limit_template", v)
		}

		counts := map[string]int{}
		var bucketIds map[string]bool
		for _, kind := range apiclient.BackupKinds {
			if !selected[kind] {
				continue
			}
			var keep func(line []byte) bool
			switch kind {
			case "file-buckets":
				keep = func(line []byte) bool {
					var b struct {
						Id string `json:"id"`
					}
					if json.Unmarshal(line, &b) != nil {
						return false
					}
					bucketIds[b.Id] = true
					return true
				}
			case "file-objects":

				keep = func(line []byte) bool {
					var o struct {
						BucketId string `json:"bucket_id"`
					}
					return json.Unmarshal(line, &o) == nil && bucketIds[o.BucketId]
				}
			}
			var n int
			err := retry(ctx, func() error {
				bucketIdsReset(kind, &bucketIds)
				var err error
				n, err = backupKind(ctx, client, stagingDir(dir), kind, key, params, keep)
				return err
			})
			if err != nil {
				return fmt.Errorf("couldn't back up the %s records: %w", kind, err)
			}
			counts[kind] = n
			fmt.Printf("  %-14s %d\n", kind, n)
		}

		warnings := 0
		if withContent {
			copied, held, bytes, problems := backupContent(ctx, client, dir, key)
			warnings = len(problems)
			fmt.Printf("  %-14s %d copied (%s), %d already in the backup", "file content", copied, humanBytes(bytes), held)
			if warnings > 0 {
				fmt.Printf(", %d could not be read", warnings)
			}
			fmt.Println()
			for i, p := range problems {
				if i == 10 {
					fmt.Printf("    and %d more\n", len(problems)-10)
					break
				}
				fmt.Println("   ", p)
			}
		}

		// The new records replace the old only now, with the content they refer
		// to in the folder, and pruning waits until they have.
		var done []string
		for _, kind := range apiclient.BackupKinds {
			if selected[kind] {
				done = append(done, kind)
			}
		}

		if err := backupfile.MarkUnfinished(dir); err != nil {
			return err
		}
		if err := commitRecords(dir, done); err != nil {
			return fmt.Errorf("couldn't save the records: %w", err)
		}

		if cmd.GetBool("prune") && withContent && warnings == 0 && cmd.GetString("limit-user") == "" {
			removed, freed, err := pruneContent(dir, key)
			if err != nil {
				return fmt.Errorf("couldn't prune old content: %w", err)
			}
			fmt.Printf("  %-14s %d removed (%s) that no file refers to\n", "pruned", removed, humanBytes(freed))
		}

		if err := backupfile.WriteManifest(dir, &backupfile.Manifest{
			Created: time.Now().UTC(), Server: info.Version, Encrypted: key != "", Counts: counts, Content: withContent,
		}); err != nil {
			return err
		}
		if err := backupfile.ClearUnfinished(dir); err != nil {
			return err
		}
		client.PostBackupComplete(ctx, &apiclient.BackupSummary{Counts: counts, Warnings: warnings})

		if warnings > 0 {
			return fmt.Errorf("the backup is complete except for the content of %d files; they may have been replaced during the backup, or no server holds them", warnings)
		}
		fmt.Println("Backup completed successfully.")
		return nil
	},
}
View Source
var FileCmd = &cli.Command{
	Name:        "file",
	Usage:       "List and restore files from a backup, and check file storage",
	Description: "Work with the file storage in a backup made by knot admin backup, and check and repair file storage on a running server.",
	MaxArgs:     cli.NoArgs,
	Commands: []*cli.Command{
		fileLsCmd,
		fileRestoreCmd,
		FsckCmd,
	},
}

FileCmd is knot admin file: working with file storage backups and checking the files on a running server.

View Source
var FsckCmd = &cli.Command{
	Name:  "fsck",
	Usage: "Check file storage on the running server for damage, and repair it",
	Description: `Check a running server's file storage for damage: file content that is missing or wrong, reference counts and indexes that disagree with the file records, statistics that have drifted, and buckets whose owner was deleted.

With --repair, content that is missing or wrong is fetched from the other servers in the cluster, counts, indexes and statistics are rebuilt from the file records, and buckets of deleted users are removed. A file whose content no server holds can't be repaired from the cluster: add --from-backup to restore it from a backup folder made by knot admin backup, or delete the file. Records of files are never removed by a check.

--deep also reads every stored file and checks its checksum, which takes a while on a large store. Writes pause briefly while counts are rebuilt, and only if they were wrong.

The check needs the Manage File Storage permission; --from-backup also needs the Backup Server permission. Choose the server with --server and --token, or --alias for one in the configuration file; with neither, the default alias is used. The exit status is non-zero while problems remain.`,
	Flags: []cli.Flag{
		&cli.BoolFlag{Name: "repair", Usage: "Fix what can be fixed."},
		&cli.BoolFlag{Name: "deep", Usage: "Check the checksum of every stored file."},
		&cli.StringFlag{Name: "from-backup", Usage: "With --repair, restore content no server holds from this backup folder."},
		&cli.BoolFlag{Name: "json", Usage: "Print the report as JSON."},
		encryptKeyFlag,
	},
	MaxArgs: cli.NoArgs,
	Run: func(ctx context.Context, cmd *cli.Command) error {
		opts := filestore.FsckOptions{Repair: cmd.GetBool("repair"), Deep: cmd.GetBool("deep")}
		backup := cmd.GetString("from-backup")
		if backup != "" && !opts.Repair {
			return errors.New("--from-backup repairs, so it needs --repair")
		}
		key := cmd.GetString("encrypt-key")
		if backup != "" {
			m, err := backupfileManifest(backup)
			if err != nil {
				return err
			}
			if m.Encrypted && key == "" {
				return errors.New("the backup is encrypted: give the key with --encrypt-key")
			}
			if !m.Content {
				return errors.New("the backup holds no file content")
			}
		}

		client, err := adminClient(cmd)
		if err != nil {
			return err
		}
		report := &filestore.FsckReport{}
		if err := client.FilesFsck(ctx, opts, report); err != nil {
			return fmt.Errorf("couldn't check the file storage: %w", cmdutil.CleanErr(err))
		}

		// Content no server holds comes from the backup.
		var restored, held int
		var notInBackup []string
		if backup != "" && report.Found[filestore.FsckMissingContent]+report.Found[filestore.FsckCorruptContent] > 0 {
			restored, held, notInBackup = restoreContent(ctx, client, backup, key)
			report.Repaired[filestore.FsckMissingContent] = min(report.Found[filestore.FsckMissingContent], report.Repaired[filestore.FsckMissingContent]+restored)
		}

		if cmd.GetBool("json") {
			if err := printJSON(report); err != nil {
				return err
			}
		} else {
			printFsck(report, opts)
			if backup != "" {
				fmt.Printf("From the backup: %d contents restored, %d already held", restored, held)
				if len(notInBackup) > 0 {
					fmt.Printf(", %d not in the backup", len(notInBackup))
				}
				fmt.Println()
			}
		}
		if n := report.Unresolved(); n > 0 {
			return fmt.Errorf("%d problems remain", n)
		}
		return nil
	},
}
View Source
var RefreshBaseImagesCmd = &cli.Command{
	Name:  "refresh-base-images",
	Usage: "Force a base image manifest refresh across the cluster",
	Description: `Force every server to fetch the base image manifest from its update URL immediately.

Each server keeps the fetched copy only if it is newer than its active catalog. By default the command fans out to all servers in the cluster (via cluster-info); pass --local-only to refresh just the connected server. Each server must have both --base-images-update-enabled and --base-images-update-url set, otherwise that server reports a conflict and keeps its current catalog.`,
	MaxArgs: cli.NoArgs,
	Flags: []cli.Flag{
		&cli.StringFlag{
			Name:    "server",
			Aliases: []string{"s"},
			Usage:   "The address of the remote server.",
			EnvVars: []string{config.CONFIG_ENV_PREFIX + "_SERVER"},
		},
		&cli.StringFlag{
			Name:    "token",
			Aliases: []string{"t"},
			Usage:   "API token for the remote server (must be valid cluster-wide).",
			EnvVars: []string{config.CONFIG_ENV_PREFIX + "_TOKEN"},
		},
		&cli.StringFlag{
			Name:         "alias",
			Aliases:      []string{"a"},
			Usage:        "The configured server alias to use.",
			DefaultValue: "default",
		},
		&cli.BoolFlag{
			Name:         "tls-skip-verify",
			Usage:        "Skip TLS verification when talking to the servers.",
			ConfigPath:   []string{"tls.skip_verify"},
			EnvVars:      []string{config.CONFIG_ENV_PREFIX + "_TLS_SKIP_VERIFY"},
			DefaultValue: true,
		},
		&cli.BoolFlag{
			Name:  "local-only",
			Usage: "Refresh only the connected server instead of fanning out across the cluster.",
		},
	},
	Run: func(ctx context.Context, cmd *cli.Command) error {
		client, err := cmdutil.GetClient(cmd)
		if err != nil {
			return err
		}
		token := client.GetAuthToken()
		skipVerify := cmd.GetBool("tls-skip-verify")
		localOnly := cmd.GetBool("local-only")

		refreshOne(ctx, labelFor(client), client)

		if localOnly {
			return nil
		}

		info, _, err := client.GetServerInfo(ctx)
		if err != nil || info == nil || !info.Clustered {
			return nil
		}

		peers, _, err := client.GetClusterInfo(ctx)
		if err != nil {
			fmt.Printf("  (couldn't enumerate cluster peers: %v; refreshed only the connected server)\n", cleanRestError(err))
			return nil
		}
		if peers == nil {
			return nil
		}

		seen := normalizeURL(client.GetBaseURL())
		for _, p := range *peers {
			ep := p.Metadata["api_endpoint"]
			if ep == "" || normalizeURL(ep) == seen {
				continue
			}
			seen += " " + normalizeURL(ep)
			peer, err := apiclient.NewClient(ep, token, skipVerify)
			if err != nil {
				fmt.Printf("  %s: skipped (%v)\n", nodeLabel(p), err)
				continue
			}
			refreshOne(ctx, nodeLabel(p), peer)
		}
		return nil
	},
}

RefreshBaseImagesCmd triggers a base image manifest refresh. By default it fans the request out to every server in the cluster: it asks the connected server for its peers (GET /api/cluster-info, which carries each node's api_endpoint) and POSTs the refresh endpoint to each one with the same token. Use --local-only to refresh just the connected server.

Each server fetches the manifest itself from its configured update URL — no server-to-server content sync — so the whole fleet converges on the same (newest) catalog without gossip.

View Source
var RenameZoneCmd = &cli.Command{
	Name:  "rename-zone",
	Usage: "Rename a Zone",
	Description: `Rename a zone.

The zone name is updated within the database however spaces and volumes are not moved.`,
	Arguments: []cli.Argument{
		&cli.StringArg{
			Name:     "old",
			Usage:    "The old zone name",
			Required: true,
		},
		&cli.StringArg{
			Name:     "new",
			Usage:    "The new zone name",
			Required: true,
		},
	},
	MaxArgs: cli.NoArgs,
	Run: func(ctx context.Context, cmd *cli.Command) error {
		oldZone := cmd.GetStringArg("old")
		newZone := cmd.GetStringArg("new")

		fmt.Println("Renaming zone", oldZone, "to", newZone)
		fmt.Print("This command will not move any spaces or volumes between zones.\n\n")

		// Prompt the user to confirm the deletion
		var confirm string
		fmt.Printf("Are you sure you want to rename zone %s (yes/no): ", oldZone)
		fmt.Scanln(&confirm)
		if confirm != "yes" {
			fmt.Println("Rename cancelled.")
			return nil
		}

		db := database.GetInstance()

		fmt.Print("Updating volumes\n")
		volumes, err := db.GetVolumes()
		if err != nil {
			return fmt.Errorf("couldn't load the volumes: %w", err)
		}

		for _, volume := range volumes {
			fmt.Print("Checking Volume: ", volume.Name)
			if volume.Zone == oldZone {
				volume.Zone = newZone
				volume.UpdatedAt = hlc.Now()
				err := db.SaveVolume(volume, []string{"Zone", "UpdatedAt"})
				if err != nil {
					fmt.Println()
					return fmt.Errorf("couldn't update volume %q: %w", volume.Name, err)
				}
				fmt.Print(" - Updated\n")
			} else {
				fmt.Print(" - Skipping\n")
			}
		}

		fmt.Print("\nUpdating spaces\n")
		spaces, err := db.GetSpaces()
		if err != nil {
			return fmt.Errorf("couldn't load the spaces: %w", err)
		}

		for _, space := range spaces {
			fmt.Print("Checking Space: ", space.Name)
			if space.Zone == oldZone {
				space.Zone = newZone
				space.UpdatedAt = hlc.Now()
				err := db.SaveSpace(space, []string{"Zone", "UpdatedAt"})
				if err != nil {
					fmt.Println()
					return fmt.Errorf("couldn't update space %q: %w", space.Name, err)
				}
				fmt.Print(" - Updated\n")
			} else {
				fmt.Print(" - Skipping\n")
			}
		}

		fmt.Print("\nZone renamed\n")
		return nil
	},
}
View Source
var ResetTOTPCmd = &cli.Command{
	Name:        "reset-totp",
	Usage:       "Reset a users TOTP",
	Description: "Clear the current TOTP for the specified user so that on next login a new secret is generated.",
	Arguments: []cli.Argument{
		&cli.StringArg{
			Name:     "email-address",
			Usage:    "The email address of the user to reset the TOTP for",
			Required: true,
		},
	},
	MaxArgs: cli.NoArgs,
	Run: func(ctx context.Context, cmd *cli.Command) error {
		email := cmd.GetStringArg("email-address")

		fmt.Println("Resetting TOTP for user: ", email)

		db := database.GetInstance()

		user, err := db.GetUserByEmail(email)
		if err != nil {
			return fmt.Errorf("couldn't find user %q: %w", email, err)
		}

		user.TOTPSecret = ""
		user.UpdatedAt = hlc.Now()

		err = db.SaveUser(user, []string{"TOTPSecret", "UpdatedAt"})
		if err != nil {
			return fmt.Errorf("couldn't save user %q: %w", email, err)
		}

		fmt.Print("\nTOTP Reset\n")
		return nil
	},
}
View Source
var RestoreCmd = &cli.Command{
	Name:  "restore",
	Usage: "Restore a backup into a server",
	Description: `Restore a backup folder made by knot admin backup into a running server, which is how a lost server is rebuilt: install knot, start a new server, and restore into it.

Restoring needs a token from a user holding the Backup Server permission. To rebuild a lost server, start a new one, create its first user (it takes the Admin and Backup User roles), and restore with that user's token. Use a username and email the backup doesn't contain, as the backup's own users are restored over it, and delete that user afterwards. The users come last, so a failed restore can simply be run again.

Records are saved over any the server already holds; file records keep their timestamps, so a newer version of a bucket or file already on the server is kept. File content is uploaded for files whose content the server doesn't hold. Use --no-content to restore the records only.

To get back a single file or folder, rather than the whole backup, use knot admin file restore.`,
	Arguments: []cli.Argument{
		&cli.StringArg{Name: "backupdir", Usage: "The backup folder", Required: true},
	},
	MaxArgs: cli.NoArgs,
	Flags: []cli.Flag{
		&cli.StringFlag{Name: "encrypt-key", Aliases: []string{"e"}, Usage: "The key the records were encrypted with.", EnvVars: []string{config.CONFIG_ENV_PREFIX + "_RESTORE_ENCRYPT_KEY"}},
		&cli.BoolFlag{Name: "no-content", Usage: "Restore the records of buckets and files but not their content."},
	},
	Run: func(ctx context.Context, cmd *cli.Command) error {
		dir := cmd.GetStringArg("backupdir")
		key := cmd.GetString("encrypt-key")

		manifest, err := backupfile.ReadManifest(dir)
		if err != nil {
			return err
		}
		if manifest.Encrypted && key == "" {
			return errors.New("the backup is encrypted: give the key with --encrypt-key")
		}
		client, err := adminClient(cmd)
		if err != nil {
			return err
		}

		fmt.Printf("Restoring the backup of %s (knot %s) from %s\n", manifest.Created.Local().Format("2006-01-02 15:04:05"), manifest.Server, dir)
		summary := &apiclient.BackupSummary{Counts: map[string]int{}}
		problems := 0

		for _, kind := range apiclient.BackupKinds {
			if _, ok := manifest.Counts[kind]; !ok {
				continue
			}
			var res *apiclient.RestoreResult
			var sent int
			err := retry(ctx, func() error {
				var err error
				res, sent, err = restoreKind(ctx, client, dir, kind, key)
				return err
			})
			if err != nil {
				return fmt.Errorf("couldn't restore the %s records: %w", kind, err)
			}
			summary.Counts[kind] = res.Restored
			fmt.Printf("  %-14s %d restored", kind, res.Restored)
			if res.Skipped > 0 {
				fmt.Printf(", %d could not be", res.Skipped)
				problems += res.Skipped
			}
			fmt.Println()
			for _, e := range res.Errors {
				fmt.Println("   ", e)
			}
			if sent != manifest.Counts[kind] {
				fmt.Printf("    the backup lists %d records, %d were read: the file may be damaged\n", manifest.Counts[kind], sent)
				problems++
			}

			if kind == "file-objects" && manifest.Content && !cmd.GetBool("no-content") {
				uploaded, held, missing := restoreContent(ctx, client, dir, key)
				fmt.Printf("  %-14s %d uploaded, %d already held", "file content", uploaded, held)
				if len(missing) > 0 {
					fmt.Printf(", %d not in the backup", len(missing))
					problems += len(missing)
				}
				fmt.Println()
				for i, m := range missing {
					if i == 10 {
						fmt.Printf("    and %d more\n", len(missing)-10)
						break
					}
					fmt.Println("   ", m)
				}
			}
		}

		summary.Warnings = problems

		client.PostRestoreComplete(ctx, summary)

		if problems > 0 {
			return fmt.Errorf("the restore finished with %d problems", problems)
		}
		fmt.Println("Restore completed successfully.")
		return nil
	},
}
View Source
var SetPasswordCmd = &cli.Command{
	Name:        "set-password",
	Usage:       "Reset a users password",
	Description: "Set a new password for the specified user.",
	Arguments: []cli.Argument{
		&cli.StringArg{
			Name:     "email-address",
			Usage:    "The email address of the user to reset the password for",
			Required: true,
		},
	},
	MaxArgs: cli.NoArgs,
	Run: func(ctx context.Context, cmd *cli.Command) error {
		email := cmd.GetStringArg("email-address")

		fmt.Println("Setting new password for user: ", email)

		fmt.Printf("Enter the new password: ")
		password, err := term.ReadPassword(int(syscall.Stdin))
		if err != nil {
			return fmt.Errorf("couldn't read the password: %w", err)
		}
		fmt.Println()

		db := database.GetInstance()

		user, err := db.GetUserByEmail(email)
		if err != nil {
			return fmt.Errorf("couldn't find user %q: %w", email, err)
		}

		user.SetPassword(string(password))
		user.UpdatedAt = hlc.Now()

		err = db.SaveUser(user, []string{"Password", "UpdatedAt"})
		if err != nil {
			return fmt.Errorf("couldn't save user %q: %w", email, err)
		}

		fmt.Print("\nPassword set\n")
		return nil
	},
}
View Source
var TemplateExportCmd = &cli.Command{
	Name:        "export",
	Usage:       "Export a template as portable YAML",
	Description: "Exports a template to a portable YAML format suitable for version control and cross-instance import. Pipe to a file with: knot admin template export name > template.yaml",
	Arguments: []cli.Argument{
		&cli.StringArg{
			Name:     "name",
			Usage:    "The template name (or ID) to export.",
			Required: true,
		},
	},
	MaxArgs: cli.NoArgs,
	Run: func(ctx context.Context, cmd *cli.Command) error {
		alias := cmd.GetString("alias")
		cfg := config.GetServerAddr(alias, cmd)
		client, err := apiclient.NewClient(cfg.HttpServer, cfg.ApiToken, cmd.GetBool("tls-skip-verify"))
		if err != nil {
			return fmt.Errorf("couldn't create the API client: %w", err)
		}

		name := cmd.GetStringArg("name")
		yaml, code, err := client.ExportTemplate(ctx, name)
		if err != nil {
			if code == 404 {
				return fmt.Errorf("template %q not found", name)
			}
			return fmt.Errorf("failed to export template: %w", err)
		}

		fmt.Print(yaml)
		return nil
	},
}
View Source
var TemplateImportCmd = &cli.Command{
	Name:        "import",
	Usage:       "Import a template from portable YAML",
	Description: "Imports a template from a YAML file (or stdin). Creates a new template on the server. Scripts are resolved by name.",
	Flags: []cli.Flag{
		&cli.StringFlag{
			Name:    "file",
			Aliases: []string{"f"},
			Usage:   "Path to a YAML file to import. If omitted, reads from stdin.",
		},
		&cli.StringFlag{
			Name:  "name",
			Usage: "Override the template name from the YAML file.",
		},
	},
	MaxArgs: cli.NoArgs,
	Run: func(ctx context.Context, cmd *cli.Command) error {
		alias := cmd.GetString("alias")
		cfg := config.GetServerAddr(alias, cmd)
		client, err := apiclient.NewClient(cfg.HttpServer, cfg.ApiToken, cmd.GetBool("tls-skip-verify"))
		if err != nil {
			return fmt.Errorf("couldn't create the API client: %w", err)
		}

		// Read YAML from file or stdin.
		var data []byte
		filePath := cmd.GetString("file")
		if filePath != "" {
			data, err = os.ReadFile(filePath)
			if err != nil {
				return fmt.Errorf("failed to read file: %w", err)
			}
		} else {
			stat, _ := os.Stdin.Stat()
			if (stat.Mode() & os.ModeCharDevice) != 0 {
				return fmt.Errorf("no input: provide --file or pipe YAML via stdin")
			}
			data, err = io.ReadAll(os.Stdin)
			if err != nil {
				return fmt.Errorf("failed to read stdin: %w", err)
			}
		}

		nameOverride := cmd.GetString("name")
		if nameOverride != "" {
			data = patchYamlName(data, nameOverride)
		}

		id, code, err := client.ImportTemplate(ctx, string(data))
		if err != nil {
			return fmt.Errorf("failed to import template: %w", err)
		}

		if code == 200 {
			fmt.Printf("Template updated successfully (ID: %s)\n", id)
		} else {
			fmt.Printf("Template imported successfully (ID: %s)\n", id)
		}
		return nil
	},
}

Functions

This section is empty.

Types

This section is empty.

Jump to

Keyboard shortcuts

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